Java核心技术卷1 第四章 对象与类

4.1 面向对象程序设计概述

类之间的关系

在类之间, 最常见的关系有
•依赖(“ uses-a”)
•聚合(“ has-a”)
•继承(“ is-a”)
依赖( dependence ), 即“ uses-a” 关系, 是一种最明显的、 最常见的关系。例如,Order
类使用 Account 类是因为 Order 对象需要访问 Account 对象查看信用状态。但是 Item 类不依
赖于 Account 类, 这是因为 Item 对象与客户账户无关。因此, 如果一个类的方法操纵另一个
类的对象,我们就说一个类依赖于另一个类。
应该尽可能地将相互依赖的类减至最少。如果类 A 不知道 B 的存在, 它就不会关心 B的任何改变(这意味着 B 的改变不会导致 A 产生任何 bug )。用软件工程的术语来说,就是让类之间的耦合度最小。
聚合(aggregation ), 即“ has-a ” 关系,是一种具体且易于理解的关系。例如, 一个Order 对象包含一些 Item 对象。聚合关系意味着类 A 的对象包含类 B 的对象。
继承( inheritance ), 即“ is-a” 关系,是一种用于表示特殊与一般关系的。例如,RushOrder类由 Order 类继承而来。在具有特殊性的 RushOrder 类中包含了一些用于优先处理的特殊方法,以及一个计算运费的不同方法;而其他的方法,如添加商品、生成账单等都是从 Order 类继承来的。一般而言, 如果类 A 扩展类 B, 类 A 不但包含从类 B 继承的方法,还会拥有一些额外的功能。

4.2使用预定义类

在 Java 中, 没有类就无法做任何事情。然而,并不是所有的类都具有面向对象特征。例如,Math 类。在程序中,可以使用 Math 类的方法, 如 Math.random, 并只需要知道方法名和参数(如果有的话,) 而不必了解它的具体实现过程。 

对象与对象变量

要想使用对象,就必须首先构造对象, 并指定其初始状态。然后,Xt对象应用方法。 在 Java 程序设计语言中, 使用构造器(constructor ) 构造新实例。构造器是一种特殊的方法, 用来构造并初始化对象。

构造器的名字应该与类名相同。因此 Date 类的构造器名为 Date。要想构造一个 Date 对
象, 需要在构造器前面加上 new 操作符,如下所示:
new Date()
这个表达式构造了一个新对象。这个对象被初始化为当前的日期和时间。
如果需要的话, 也可以将这个对象传递给一个方法:
System.out.printTn(new DateO);
或者, 也可以将一个方法应用于刚刚创建的对象。Date 类中有一个 toString 方法。这
个方法将返回日期的字符串描述。下面的语句可以说明如何将 toString 方法应用于新构造的
Date 对象上。
String s = new Date().toString();
在这两个例子中, 构造的对象仅使用了一次。通常, 希望构造的对象可以多次使用, 因
此,需要将对象存放在一个变量中:
Date birthday = new Date();
如图显示了引用新构造的对象变量 birthday

在对象与对象变量之间存在着一个重要的区别。例如, 语句
Date deadline; // deadline doesn't refer to any object
定义了一个对象变量 deadline, 它可以引用 Date 类型的对象。但是,一定要认识到: 变量
deadline 不是一个对象, 实际上也没有引用对象。此时,不能将任何 Date 方法应用于这个变量上。语句
s = deadline.toStringO; // not yet
将产生编译错误。必须首先初始化变量 deadline, 这里有两个选择。当然,可以用新构造的对象初始化这个变量:
deadline = new Date();
也让这个变量引用一个已存在的对象:
deadline = birthday;
现在,这两个变量引用同一个对象

 

一定要认识到: 一个对象变量并没有实际包含一个对象,而仅仅引用一个对象。
在 Java 中,任何对象变量的值都是对存储在另外一个地方的一个对象的引用。new 操作符的返回值也是一个引用。下列语句:
Date deadline = new Date();
有两个部分。表达式 new Date() 构造了一个 Date 类型的对象,并且它的值是对新创建对象的引用。这个引用存储在变量 deadline 中。
可以显式地将对象变量设置为null, 表明这个对象变量目前没有引用任何对象。
deadline = null;
if (deadline != null)
        System.out.println(deadline);
如果将一个方法应用于一个值为 null 的对象上,那么就会产生运行时错误。
birthday = null;
String s = birthday.toStringQ; // runtime error!
局部变量不会自动地初始化为 null,而必须通过调用 new 或将它们设置为 null 进行初始化。

很多人错误地认为 Java 对象变量与 C++ 的引用类似。然而,在 C++ 中没有空引用, 并且引用不能被赋值。可以将 Java 的对象变量看作 C++ 的对象指针。例如,

Date birthday; // Java

实际上,等同于

Date* birthday; // C++

一旦理解了这一点, 一切问题就迎刃而解了。 当然,一个 Date* 指针只能通过调用 new 进行初始化。就这一点而言,C++ 与 Java 的语法几乎是一样的。

Date* birthday = new Date(); // C++

如果把一个变量的值賦给另一个变量, 两个变量就指向同一个日期,即它们是同一 个对象的指针。 在 Java 中的 null 引用对应 C++ 中的 NULL 指针。 所有的 Java 对象都存储在堆中。 当一个对象包含另一个对象变量时, 这个变量依然 包含着指向另一个堆对象的指针。

 LocalDate 类&&更改器方法与访问器方法 

不要使用构造器来构造 LocalDate 类的对象。实际上,应当使用静态工厂方法 (factory
method) 代表你调用构造器。下面的表达式
Local Date.now()
会构造一个新对象,表示构造这个对象时的日期。
可以提供年、 月和日来构造对应一个特定日期的对象:
LocalDate.of(1999, 12, 31)
当然, 通常都希望将构造的对象保存在一个对象变量中:
LocalDate newYearsEve = Local Date.of(1999, 12, 31);
一旦有 了一个 LocalDate 对象, 可以用方法 getYear、 getMonthValue 和 getDayOfMonth
得到年、月和日:
int year = newYearsEve.getYearO; // 1999
int month = newYearsEve.getMonthValueO; // 12
int day = newYearsEve.getDayOfMonth(); // 31
看起来这似乎没有多大的意义, 因为这正是构造对象时使用的那些值。不过,有时可能
某个日期是计算得到的,你希望调用这些方法来得到更多信息。例如, plusDays 方法会得到一个新的 LocalDate, 如果把应用这个方法的对象称为当前对象,这个新日期对象则是距当
前对象指定天数的一个新日期:
LocalDate aThousandDaysLater = newYearsEve.piusDays(1000):
year = aThousandDaysLater.getYearO;// 2002
month = aThousandDaysLater.getMonthValueO; // 09
day = aThousandDaysLater.getDayOfMonth(); // 26 


再来看上一节中的 plusDays 方法调用:
LocalDate aThousandDaysLater = newYearsEve.plusDays(1000);
这个调用之后 newYeareEve 会有什么变化? 它会改为 1000 天之后的日期吗? 事实上,并没有。plusDays 方法会生成一个新的 LocalDate 对象,然后把这个新对象赋给aThousandDaysLater变量。原来的对象不做任何改动。 我们说 plusDays 方法没有更改调用这个方法的对象。(这类似于第 3章中见过的 String 类的 toUpperCase 方法。在一个字符串上调用 toUpperCase 时,这个字符串仍保持不变,会返回一个将字符大写的新字符串。)
Java 库的一个较早版本曾经有另一个类来处理日历,名为 GregorianCalendar。 可以如下
为这个类表示的一个日期增加 1000 天:
Cregori anCalendar someDay = new CregorianCalendar(1999, 11, 31);
// Odd feature of that cl ass: month numbers go from 0 to 11
someDay.add(Calendar.DAY_0F_M0NTH,1000);
与 LocalDate.plusDays 方法不同,GregorianCalendar.add 方法是一个更改器方法 ( mutator
method ) 调用这个方法后,someDay 对象的状态会改变。可以如下査看新状态:
year = someDay.get(Calendar.YEAR); // 2002
month = someDay.get(Calendar.MONTH)+ 1; // 09
day = someDay.get(Ca1endar.DAY_0F_M0NTH); // 26
正是因为这个原因,我们将变量命名为 someDay 而不是 newYearsEve 调用这个更改器方法之后,它不再是新年前夜。
相 反, 只 访 问 对 象 而 不 修 改 对 象 的 方 法 有 时 称 为 访 问 器 方 法 例 如,
LocalDate.getYear 和 GregorianCalendar.get 就是访问器方法。


 下面用一个应用 LocalDate 类的程序来结束本节内容的论述。这个程序将显示当前月的
日历,其格式为:

当前的日用一个 * 号标记。可以看到,这个程序需要解决如何计算某月份的天数以及一个给
定日期相应是星期几。
下面看一下这个程序的关键步骤。首先, 构造了一个日历对象, 并用当前的日期和时间
进行初始化。
Local Date date = LocalDate.now ();
下面获得当前的月和日。
int month = date.getMonthValue ();
int today = date.getDayOfMonth();
然后, 将 date 设置为这个月的第一天, 并得到这一天为星期几。
date = date.minusDays (today - 1); // Set to start of month
DayOfWeek weekday = date.getDayOfWeek();
int value = weekday .getValue(); // 1 = Monday , .. . 7 = Sunday
变量 weekday 设置为 DayOfWeek 类型的对象。我们调用这个对象的 getValue 方法来得
到星期几的一个数值。这会得到一个整数, 这里遵循国际惯例, 即周末是一周的末尾, 星期一就返冋 1, 星期二返回 2, 依此类推。星期日则返回 7。
注意,日历的第一行是缩进的, 使得月份的第一天指向相应的星期几。下面的代码会打
印表头和第一行的缩进:
System . out.println("Mon Tue Wed Thu Fri Sat Sun");
for (int i = 1; i < value ; i++)
        System,out .print(" ");
现在我们来打印日历的主体。进入一个循环, 其中 date 遍历一个月中的每一天。
每次迭代时, 打印 R 期值。 如果 date 是当前日期, 这个日期则用一个 *标记。接下来,
把 date 推进到下一天。如果到达新的一周的第一天, 则换行打印:
while (date .getMonthValueO == month)
{
        System.out.printf("%3d" , date.getDayOfMonth());
        if (date.getDayOfMonth() == today)
                System.out.print("*");
        else
                System.out.print("  ");
        date = date.plusDays(l);
        if (date.getDayOfWeekQ .getValueQ = 1)

                 System.out.printlnO;
}
什么时候结束呢? 我们不知道这个月有几天, 是 31 天、30 天、29 天还是 28 天。实际上,只要 date 还在当月就要继续迭代。

 完整代码:

package chapter04;

import java.time.*;

public class CalendarTest {
    public static void main(String[] args){
        LocalDate date = LocalDate.now();
        int month = date.getMonthValue();
        int today = date.getDayOfMonth();
        date = date.minusDays(today - 1); // Set to start of month
        DayOfWeek weekday = date.getDayOfWeek();
        int value = weekday.getValue();
        System.out.println("Mon Tue Wed Thu Fri Sat Sun");
        for (int i = 1; i < value; i++)
            System.out.print("    ");
        while (date.getMonthValue() == month){
            System.out.printf("%3d", date.getDayOfMonth());
            if (date.getDayOfMonth() == today)
                System.out.print("*");
            else
                System.out.print(" ");
            date = date.plusDays(1);
            if (date.getDayOfWeek().getValue() == 1)
                System.out.println();
        }
        if (date.getDayOfWeek() .getValue() != 1)
            System.out.println();
    }
}


LocalDate的API

static Local Time now( )
构造一个表示当前日期的对象。

static LocalTime of(int year, int month , int day )
构造一个表示给定日期的对象。

int getYear( )
int getMonthValue( )
int getDayOfMonth( )
得到当前日期的年、 月和曰。

DayOfWeek getDayOfWeek
得到当前日期是星期几, 作为 DayOfWeek 类的一个实例返回。 调用 getValue 来得到
1 ~ 7 之间的一个数, 表示这是星期几, 1 表示星期一, 7 表示星期日。

Local Date piusDays(int n )
Local Date minusDays(int n)
生成当前日期之后或之前 n 天的日期。

4.3用户自定义类

现在开始学习如何设计复杂应用程序所需要的各种主力类( workhorse class。) 通常, 这些类没有 main 方法, 却有自己的实例域和实例方法。 要想创建一个完整的程序, 应该将若干类组合在一起, 其中只有一个类有 main 方法

对于以下程序:

import java.time.*;

public class EmployeeTest
{
    public static void main(String口 args)
    {
        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); 

        ...
        ...
    }
}

class Employee
{
    private String name;
    private double salary;
    private Local Date hireDay;
    public Employee (String n , double s, int year, int month , int day)
    {
        name = n;
        salary = s;
        hireDay = Local Date,of(year, month, day);
    }
    public String getName()
    {
        return name;
    }
    public String getName()

    {

           return name;

    }
    ...
    ...

}


在这个示例程序中包含两个类:Employee类和带有 public 访问修饰符的 EmployeeTest
类。EmployeeTest 类包含了 main 方法,其中使用了前面介绍的指令。
源文件名是 EmployeeTest.java,这是因为文件名必须与 public 类的名字相匹配。在一个
源文件中, 只能有一个公有类,但可以有任意数目的非公有类。

接下来,当编译这段源代码的时候, 编译器将在目录下创建两个类文件:

EmployeeTest.class 和 Employee.class
将程序中包含 main方法的类名提供给字节码解释器, 以便启动这个程序:
java EmployeeTest
字节码解释器开始运行 EmployeeTest 类的 main方法中的代码。在这段代码中,先后构
造了三个新 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 类的所有方法都被标记为 public。 关键字 public 意味着任何类的任何方法都可以调用这 些方法。

需要注意在 Employee 类的实例中有三个实例域用来存放将要操作的数据:

private String name;

private double salary;

private Local Date hireDay;

关键字 private 确保只有 Employee 类自身的方法能够访问这些实例域, 而其他类的方法不能 够读写这些域。


可以用 public 标记实例域, 但这是一种极为不提倡的做法 , public 数据域允许程序中的任何方法对其进行读取和修改,这就完全破坏了封装。 任何类的任何方法都可以修改public域, 从我们的经验来看, 某些代码将使用这种存取权限,而这并不我们所希望的, 因此, 这里强烈建议将实例域标记为 private, 最后,请注意,有两个实例域本身就是对象:

name 域是 String 类对象, hireDay 域是 LocalDate 类对象。这种情形十分常见:类通常包括类型属于某个类类型的实例域。

 简单了解构造器

下面先看看 Employee 类的构造器:
public Employee(String n, double s, int year, int month, int day)
{
    name = n;
    salary = s;
    Local Date hireDay = Local Date.of(year, month, day);
}
可以看到, 构造器与类同名。在构造 Employee 类的对象时, 构造器会运行,以便将实例域
初始化为所希望的状态。
例如, 当使用下面这条代码创建 Employee 类实例时:
new Eraployee("]ames Bond", 100000, 1950, 1, 1)
将会把实例域设置为:
name = "James Bond";
salary = 100000;
hireDay = LocalDate.of(1950, 1, 1); // January 1, 1950
构造器与其他的方法有一个重要的不同。构造器总是伴随着 new 操作符的执行被调用,
而不能对一个已经存在的对象调用构造器来达到重新设置实例域的目的。
例如,
janes.EmployeeCJames Bond", 250000, 1950, 1, 1) // ERROR
将产生编译错误。
本章稍后还会更加详细地介绍有关构造器的内容。现在只需要记住:
•构造器与类同名
•每个类可以有一个以上的构造器
•构造器可以有 0 个、1 个或多个参数
•构造器没有返回值
•构造器总是伴随着 new 操作一起调用

Java 构造器的工作方式与 C++ —样。但是,要记住所有的 Java 对象都是在堆中构造的, 构造器总是伴随着 new 操作符一起使用。C++ 程序员最易犯的错误就是忘
记 new 操作符:
Employee number007("]anie5 Bond", 100000, 1950, 1, 1);// C++, not Java
这条语句在 C++ 中能够正常运行,但在 Java 中却不行。


警告: 请注意, 不要在构造器中定义与实例域重名的局部变量。例如, 下面的构造器将
无法设置 salary。

public Employee(String n, double s, . .
{
    String name = n; // Error
    double salary = s; // Error
}
这个构造器声明了局部变量 name 和 salary。这些变量只能在构造器内部访问。这些变量屏蔽了同名的实例域有些程序设计者常常不假思索地写出 这类代码, 因为他们已经习惯增加这类数据类型。这种错误很难被检查出来, 因此,必须注意在所有的方法中不要命名与实例域同名的变量。

 隐式参数与显式参数

方法用于操作对象以及存取它们的实例域。例如,方法:
public void raiseSalary(double byPercent)
{
    double raise = salary * byPercent / 100;
    salary += raise;
}
将调用这个方法的对象的 salary 实例域设置为新值。看看下面这个调用:
number007. raiseSalary(5);
它的结果将 number007.salary 域的值增加 5%。具体地说,这个调用将执行下列指令:
double raise = nuaber007.salary * 5 / 100;
nuiber007.salary += raise;
raiseSalary 方法有两个参数。 第一个参数称为隐式 ( implicit ) 参数,是出现在方法名前的
Employee 类对象。第二个参数位于方法名后面括号中的数值,这是一个显式 ( explicit) 参
数 ( 有些人把隐式参数称为方法调用的目标或接收者。)
可以看到,显式参数是明显地列在方法声明中的, 例如 double byPercent。隐式参数没有出现在方法声明中。
在每一个方法中,关键字 this 表示隐式参数。 如果需要的话,可以用下列方式编写raiseSalary 方法:
public void raiseSalary(double byPercent)
{
double raise = this.salary * byPercent / 100;
this.sal ary += raise;
}
有些程序员更偏爱这样的风格,因为这样可以将实例域与局部变量明显地区分开来。

封装的优点 

 public double getSalaryO
{
    return salary;
}
public LocalDate getHireDay()
{
    return hireDay;
}
这些都是典型的访问器方法。由于它们只返回实例域值, 因此又称为域访问器。将 name、 salary 和 hireDay 域标记为 public , 以此来取代独立的访问器方法会不会更容易些呢?关键在于 name 是一个只读域。一旦在构造器中设置完毕,就没有任何一个办法可以对它进行修改,这样来确保 name 域不会受到外界的破坏。虽然 salary 不是只读域,但是它只能用 raiseSalary 方法修改。特别是一旦这个域值出现了错误, 只要调试这个方法就可以了。如果 salary 域是 public 的,破坏这个域值的捣乱者有可能会出没在任何地方。在有些时候, 需要获得或设置实例域的值。因此,应该提供下面三项内容:
•  一 私有的数据域;
•  一 公有的域访问器方法;
•  一个公有的域更改器方法。


注意不要编写返回引用可变对象的访问器方法。在 Employee 类中就违反了这个设计原则, 其中的 getHireDay 方法返回了一个 Date 类对象:
class Employee
{
    private Date hireDay ;
    ...

    public Date getHireDay()
    {
        return hireDay; // Bad
    }

    '''
}
LocalDate 类没有更改器方法,与之不同, Date 类有一个更改器方法 setTime, 可以
在这里设置毫秒数。Date 对象是可变的, 这一点就破坏了封装性! 请看下面这段代码:
Employee harry = . . . ;
Date d = harry.getHireDayO ;
double tenYearsInMilliSeconds = 10 * 365.25 * 24 * 60 * 60 * 1000;
d.setTime(d.getTime() - (long) tenYearsInMilliSeconds);
// let's give Harry ten years of added seniority
出错的原因很微妙。d 和 harry.hireDay 引用同一个对象(请参见图 4-5 )。对 d 调用更改器方法就可以自动地改变这个雇员对象的私有状态!


 

如果需要返回一个可变对象的引用, 应该首先对它进行克隆(clone )。对象 clone 是
指存放在另一个位置上的对象副本。 有关对象 clone 的详细内容将在第 6 章中讨论。 下
面是修改后的代码:
class Employee
{
    public Date getHireDay()
    {
        return (Date) hireDay.cloneO; // Ok
    }

    ...

}

凭经验可知, 如果需要返回一个可变数据域的拷贝,就应该使用 clone。

基于类的访问权限 

从前面已经知道,方法可以访问所调用对象的私有数据。一个方法可以访问所属类的所有
对象的私有数据
,这令很多人感到奇怪!例如,下面看一下用来比较两个雇员的 equals 方法。
class Employee
{
    public boolean equals(Employee other)
    {
        return name.equals(other.name);
    }
}
典型的调用方式是
if (harry,equals(boss)) . . .
这个方法访问 harry 的私有域, 这点并不会让人奇怪,然而, 它还访问了 boss 的私有域。这是合法的, 其原因是 boss 是 Employee 类对象, 而 Employee 类的方法可以访问 Employee 类的任何一个对象的私有域。C++ 也有同样的原则。方法可以访问所属类的私有特性( feature ), 而不仅限于访问隐式参数的私有特性。 

 final 实例域

可以将实例域定义为 final。 构建对象时必须初始化这样的域。也就是说, 必须确保在每一个构造器执行之后,这个域的值被设置, 并且在后面的操作中, 不能够再对它进行修改。
例如,可以将 Employee 类中的 name 域声明为 final, 因为在对象构建之后,这个值不会再
被修改, 即没有 setName 方法。
class Employee
{
        private final String name;

        …
}
final 修饰符大都应用于基本 (primitive ) 类型域,或不可变(immutable) 类的域(如果类
中的每个方法都不会改变其对象, 这种类就是不可变的类。例如,String类就是一个不可变
的类)。
对于可变的类, 使用 final 修饰符可能会对读者造成混乱。例如,
private final StringBuiIcier evaluations;
在 Employee 构造器中会初始化为
evaluations = new StringBuilder();
final 关键字只是表示存储在 evaluations 变量中的对象引用不会再指示其他 StringBuilder
对象。不过这个对象可以更改:

public void giveGoldStarO
{
        evaluations.append(LocalDate.now() + ": Gold star!\n");
}

4.4 静态域与静态方法 

静态域 

如果将域定义为 static, 每个类中只有一个这样的域。而每一个对象对于所有的实例域
却都有自己的一份拷贝。例如, 假定需要给每一个雇员賦予唯一的标识码。这里给 Employee类添加一个实例域 id 和一个静态域 nextld:
class Employee
{
    private static int nextld = 1;
    private int id;
}
现在, 每一个雇员对象都有一个自己的 id 域, 但这个类的所有实例将共享一个 nextld域。换句话说, 如果有 1000 个 Employee 类的对象, 则有 1000 个实例域 id。但是, 只有一
个静态域 nextld。即使没有一个雇员对象, 静态域 nextld 也存在。
它属于类,而不属于任何独立的对象。在绝大多数的面向对象程序设计语言中, 静态域被称为类域。 


下面实现一个简单的方法:
public void setldO
{
    id = nextld;
    nextld++;
}
假定为 harry 设定雇员标识码:
harry.setld();
harry 的 id 域被设置为静态域 nextld 当前的值,并且静态域 nextld 的值加 1:
harry.id = Employee.nextld;
Employee.nextId++;

静态常量 

静态变量使用得比较少,但静态常量却使用得比较多。例如, 在 Math 类中定义了一个
静态常量:
public class Hath
{
    public static final double PI = 3.14159265358979323846;
}
在程序中,可以采用 Math.PI 的形式获得这个常量。如果关键字 static 被省略, PI 就变成了 Math 类的一个实例域。需要通过 Math 类的对象访问 PI,并且每一个 Math 对象都有它自己的一份 PI 拷贝。另一个多次使用的静态常量是 System.out。它在 System 类中声明:
public class System
{
    public static final PrintStream out = . . .;


}
前面曾经提到过,由于每个类对象都可以对公有域进行修改,所以,最好不要将域设计为 public。然而, 公有常量(即 final 域)却没问题。因为 out 被声明为 final, 所以,不允许
再将其他打印流赋给它:
System.out = new PrintStrean(. . .); // Error out is final
注意:如果查看一下 System 类, 就会发现有一个 setOut 方法, 它可以将 System.out 设
置为不同的流。 读者可能会感到奇怪, 为什么这个方法可以修改 final 变量的值。原因在
于, setOut 方法是一个本地方法, 而不是用 Java 语言实现的。本地方法可以绕过 Java 语
言的存取控制机制。
这是一种特殊的方法, 在自己编写程序时, 不应该这样处理。

静态方法 

静态方法是一种不能向对象实施操作的方法。例如, Math 类的 pow 方法就是一个静态方法。表达式Math.pow(x, a)计算x^a, 不使用任何 Math对象。换句话说,没有隐式的参数。可以认为方法是没有 this参数的方法(在一给非静态的方法中,this 参数表示这个方法的隐式参数)。Employee 类的静态方法不能访问 Id 实例域, 因为它不能操作对象。但是,静态方法可以访问自身类中的静态域。下面是使用这种静态方法的一个示例:
public static int getNextl()
{
    return nextld; // returns static field
}
可以通过类名调用这个方法:
int n = Employee.getNextld();
注意:可以使用对象调用静态方法。例如, 如果 harry 是一个 Employee 对象, 可以用
harry.getNextId( ) 代替 Employee.getNextId( ), 不过,这种方式很容易造成混淆,其原因
是 getNextld 方法计算的结果与 harry 毫无关系。我们建议使用类名, 而不是对象来调用
静态方法。
在下面两种情况下使用静态方法:
•  一 方法不需要访问对象状态,其所需参数都是通过显式参数提供(例如:Math.pow) 0
•  一个方法只需要访问类的静态域,例如:Employee.getNextld() 。 

 工厂方法 

静态方法还有另外一种常见的用途。类似 LocalDate 和 NumberFormat 的类使用静态工
厂方法( factory method) 来构造对象。你已经见过工厂方法 LocalDate.now 和LocalDate.of。
NumberFormat 类如下使用工厂方法生成不同风格的格式化对象:
NumberFormat currencyFormatter = NumberFormat.getCurrencylnstanceO;
NumberFormat percentFormatter = NumberFormat.getPercentlnstance();
double x = 0.1;
System.out.println(currencyFormatter.format(x)); // prints SO.10
System.out.println(percentFomatter.format(x)); // prints 10%


为什么 NumberFormat 类不利用构造器完成这些操作呢? 这主要有两个原因:
• 无法命名构造器。构造器的名字必须与类名相同。但是, 这里希望将得到的货币实例
和百分比实例采用不用的名字。
• 当使用构造器时,无法改变所构造的对象类型。而 Factory 方法将返回一个 DecimalFormat
类对象,这是 NumberFormat 的子类 。

 main 方法

需要注意,不需要使用对象调用静态方法。例如,不需要构造 Math 类对象就可以调用Math.pow。
同理, main 方法也是一个静态方法。
public class Application
{
    public static void main(StringD args)
    {
        // construct objects here
    }
}
main 方法不对任何对象进行操作。事实上,在启动程序时还没有任何一个对象。静态的main 方法将执行并创建程序所需要的对象。 

4.5方法参数 

按值调用 (call by value) 表示方法接收的是调用者提供的值,而按引用调用(all by reference)
表示方法接收的是调用者提供的变量地址。一个方法可以修改传递引用所对应的变量值,而
不能修改传递值调用所对应的变量值。
Java 程序设计语言总是采用按值调用。也就是说, 方法得到的是所有参数值的一个拷贝,特别是,方法不能修改传递给它的任何参数变量的内容。
例如, 考虑下面的调用:
double percent = 10;
harry.raiseSalary(percent);
不必理睬这个方法的具体实现, 在方法调用之后, percent 的值还是 10。
下面再仔细地研究一下这种情况。假定一个方法试图将一个参数值增加至 3 倍:
public static void tripieValue(double x) // doesn't work
{
        x = 3 *
        x;
}
然后调用这个方法:
double percent = 10;
tripieValue(percent);
调用这个方法之后,percent 的值还是 10。下面看一下具体的执行过程:
1 ) x 被初始化为 percent 值的一个拷贝(也就是 10 ) 。
2 ) x 被乘以 3后等于 30。 但是 percent 仍然是 10 。
3 ) 这个方法结束之后,参数变量 X 不再使用。
然而,方法参数共有两种类型:
•基本数据类型(数字、布尔值)
•对象引用


一个方法不可能修改一个基本数据类型的参数。而对象引用作为参数就不同了,可以很容易地利用下面这个方法实现将一个雇员的薪金提高两倍的操作:
public static void tri pi eSal ary (Employee x) // works
{
    x.raiseSa1ary(200);
}
当调用
harry = new Employee(. . .);
tripleSalary(harry);
时,具体的执行过程为:
1 ) X 被初始化为 harry 值的拷贝,这里是一个对象的引用。
2 ) raiseSalary 方法应用于这个对象引用。x 和 harry 同时引用的那个 Employee 对象的薪
金提高了 200%。
3 ) 方法结束后,参数变量 x 不再使用。当然,对象变量 harry 继续引用那个薪金增至 3
倍的雇员对象。


实现一个改变对象参数状态的方法并不是一件难事。理由很简单,方法得到的是对象引用的拷贝,对象引用及其他的拷贝同时引用同一个对象。
很多程序设计语言(特别是, C++ 和 Pascal) 提供了两种参数传递的方式:值调用和引
用调用。有些程序员认为 Java 程序设计语言对对象采用的是引用调用,实际上, 这种理解是不对的。由于这种误解具有一定的普遍性,所以下面给出一个反例来详细地阐述一下这个问题。
首先,编写一个交换两个雇员对象的方法:
public static void swap(Employee x , Employee y) // doesn't work
{
    Employee temp = x;
    x = y;
    y = temp;
}

如果 Java 对对象采用的是按引用调用,那么这个方法就应该能够实现交换数据的效果:
Employee a = new Employee("Alice", . . .);
Employee b = new Employee("Bob", . . .);
swap(a, b);
// does a now refer to Bob, b to Alice?
但是,方法并没有改变存储在变量 a 和 b 中的对象引用。swap 方法的参数 x 和 y 被初始
化为两个对象引用的拷贝,这个方法交换的是这两个拷贝。

// x refers to Alice, y to Bob
Employee temp = x;
x = y;
y = temp;
// now x refers to Bob, y to Alice
最终,白费力气。在方法结束时参数变量 X 和 y 被丢弃了。原来的变量 a 和 b 仍然引用
这个方法调用之前所引用的对象。

​​​​​​​这个过程说明:Java 程序设计语言对对象采用的不是引用调用,实际上, 对象引用是按
值传递的。

下面总结一下 Java 中方法参数的使用情况:
•  一个方法不能修改一个基本数据类型的参数(即数值型或布尔型)。
•  一个方法可以改变一个对象参数的状态。
•  一个方法不能让对象参数引用一个新的对象。

4.6 对 象 构 造 

重载 

 有些类有多个构造器。例如, 可以如下构造一个空的 StringBuilder 对象:
StringBuilder messages = new StringBuilderO;
或者, 可以指定一个初始字符串:
StringBuilder todoList = new StringBuilderC'To do:\n";)
这种特征叫做重载( overloading。) 如果多个方法(比如, StringBuilder 构造器方法)有
相同的名字、 不同的参数,便产生了重载。
编译器必须挑选出具体执行哪个方法,它通过用
各个方法给出的参数类型与特定方法调用所使用的值类型进行匹配来挑选出相应的方法。如
果编译器找不到匹配的参数, 就会产生编译时错误,因为根本不存在匹配, 或者没有一个比
其他的更好。(这个过程被称为重载解析(overloading resolution)。)
注释:Java 允许重载任何方法, 而不只是构造器方法。因此,要完整地描述一个方法,
需要指出方法名以及参数类型。这叫做方法的签名(signature)。例如, String 类有 4 个
称为 indexOf 的公有方法。它们的签名是
indexOf(int)
indexOf(int, int)
indexOf(String)
indexOf(String, int)
返回类型不是方法签名的一部分。也就是说, 不能有两个名字相同、 参数类型也相同却返回不同类型值的方法。

默认域初始化 

如果在构造器中没有显式地给域赋予初值,那么就会被自动地赋为默认值: 数值为 0、布尔值为 false、 对象引用为 null。然而,只有缺少程序设计经验的人才会这样做。确实, 如果不明确地对域进行初始化,就会影响程序代码的可读性。
注释: 这是域与局部变量的主要不同点。 必须明确地初始化方法中的局部变量。但是,如果没有初始化类中的域, 将会被自动初始化为默认值( 0、false 或 null )。例如, 仔细看一下 Employee 类。 假定没有在构造器中对某些域进行初始化, 就会默认地将 salary 域初始化为 0, 将 name 和 hireDay 域初始化为 null。但是,这并不是一种良好的编程习惯。 如果此时调用 getName 方法或 getHireDay 方法,则会得到一个 null 引用,这应该不是我们所希望的结果:
LocalDate h = harry.getHireDayO;
int year = h.getYearQ;// throws exception if h is null 

无参数的构造器 

很多类都包含一个无参数的构造函数,对象由无参数构造函数创建时, 其状态会设置为适当的默认值。 例如, 以下是 Employee 类的无参数构造函数:
public Employee0
{
        name = "";
        salary = 0;
        hireDay = LocalDate,now();
}
如果在编写一个类时没有编写构造器, 那么系统就会提供一个无参数构造器。这个构造器将所有的实例域设置为默认值。于是, 实例域中的数值型数据设置为 0、 布尔型数据设置
为 false、 所有对象变量将设置为 null。

如果类中提供了至少一个构造器, 但是没有提供无参数的构造器, 则在构造对象时如果没有提供参数就会被视为不合法。例如, Employee 类提供了一个简单的构造器:
Employee(String name, double salary, int y, int ra , int d)
对于这个类,构造默认的雇员属于不合法。也就是, 调用
e = new Eraployee();
将会产生错误。
警告: 请记住,仅当类没有提供任何构造器的时候, 系统才会提供一个默认的构造器
如果在编写类的时候, 给出了一个构造器, 哪怕是很简单的, 要想让这个类的用户能够
采用下列方式构造实例:
new ClassName()
就必须提供一个默认的构造器 ( 即不带参数的构造器)。 当然,如果希望所有域被赋予默认值, 可以采用下列格式:
public ClassName()

{

}

显示域初始化 

通过重载类的构造器方法,可以采用多种形式设置类的实例域的初始状态。确保不管怎样调用构造器,每个实例域都可以被设置为一个有意义的初值,这是一种很好的设计习惯。
可以在类定义中, 直接将一个值赋给任何域。例如:
class Employee
{
    private String name = "Alice";
    . . .
}
在执行构造器之前,先执行赋值操作。当一个类的所有构造器都希望把相同的值赋予某个特定的实例域时,这种方式特别有用。初始值不一定是常量值。在下面的例子中, 可以调用方法对域进行初始化。仔细看一下Employee 类,其中每个雇员有一个 id 域。可以使用下列方式进行初始化:
class Employee
{
    private static int nextld;
    private int id = assignld();
    private static int assignl();
    {
        int r = nextld;
        nextld++;
        return r;
    }
}


在执行构造器之前,先执行赋值操作。如果构造器未对某个域初始化,此域的值为设定的初值,如果构造器对该域进行了初始化,那么该域的值变为构造器所赋的值。

以下程序为简单的演示:

package chapter04;

public class ConstructorTest {
    public static void main(String[] args){
        Employee e1 = new Employee();
        System.out.println(e1.getId());
        Employee e2 = new Employee(5);
        System.out.println(e2.getId());
    }
}
class Employee{
    private int id = 3;

    public Employee(int n){
        this.id=n;
    }

    public Employee(){}
    public int getId() {
        return id;
    }
}

 调用另一个构造器

关键字 this 引用方法的隐式参数。然而,这个关键字还有另外一个含义。如果构造器的第一个语句形如 this(...), 这个构造器将调用同一个类的另一个构造器。下面是一个典型的例子:
public Employee(double s)
{
    // calls Employee(String, double)
    this("Employee #" + nextld, s);
    nextld++;
}
当调用 new Employee(60000) 时, Employee(double) 构造器将调用 Employee(String,double)构造器。 

初始化块 

前面已经讲过两种初始化数据域的方法:
•在构造器中设置值
•在声明中赋值
实际上,Java 还有第三种机制, 称为初始化块
(initializationblock)。在一个类的声明中,
可以包含多个代码块。只要构造类的对象,这些块就会被执行。例如,
class Employee
{
    private static int nextld;
    private int id;
    private String name;
    private double salary;
    // object initialization block
    {
        id = nextld;
        nextld++;
    }

    public Employee(String n, double s)
    {
        name = n;
        salary = s;
    public Employee()
    {
        name ="";
        salary = 0;
    }
    . . .
}
在这个示例中,无论使用哪个构造器构造对象,id 域都在对象初始化块中被初始化。首
先运行初始化块,然后才运行构造器的主体部分。

这种机制不是必需的,也不常见。通常会直接将初始化代码放在构造器中。
 注释: 即使在类的后面定义, 仍然可以在初始化块中设置域。但是, 根据 Java 语言规范,建议将初始化块放在域定义之后。
由于初始化数据域有多种途径,所以列出构造过程的所有路径可能相当混乱。下面是调用构造器的具体处理步骤:
1 ) 所有数据域被初始化为默认值(0、false 或 null。)
2 ) 按照在类声明中出现的次序, 依次执行所有域初始化语句和初始化块。
3 ) 如果构造器第一行调用了第二个构造器,则执行第二个构造器主体
4 ) 执行这个构造器的主体.

可以通过提供一个初始化值, 或者使用一个静态的初始化块来对静态域进行初始化。前面已经介绍过第一种机制:
private static int nextld = 1;
如果对类的静态域进行初始化的代码比较复杂,那么可以使用静态的初始化块。
将代码放在一个块中,并标记关键字 static。下面是一个示例。其功能是将雇员 ID 的起
始值赋予一个小于 10 000 的随机整数。
// static initialization block
static
{
    Random generator = new Random();//构造一个随机数生成器
    nextld = generator.nextlnt(1000);  //返回一个 0 ~ n-1之间的随机数。
}
在类第一次加载的时候, 将会进行静态域的初始化。与实例域一样,除非将它们显式地设置成其他值, 否则默认的初始值是 0、 false 或 null。 所有的静态初始化语句以及静态初始化块都将依照类定义的顺序执行。

对象析构与 finalize 方法 

 由于 Java 有自动的垃圾回收器,不需要人工回收内存, 所以 Java 不支持析构器。 当然,某些对象使用了内存之外的其他资源, 例如,文件或使用了系统资源的另一个对象的句柄。在这种情况下,当资源不再需要时, 将其回收和再利用将显得十分重要。 可以为任何一个类添加 finalize 方法。finalize 方法将在垃圾回收器清除对象之前调用。 在实际应用中,不要依赖于使用 finalize 方法回收任何短缺的资源, 这是因为很难知道这个 方法什么时候才能够调用。

 4.7包

Java 允许使用包( package > 将类组织起来。借助于包可以方便地组织自己的代码,并将自己的代码与别人提供的代码库分开管理。 标准的 Java 类库分布在多个包中,包括 java.lang、java.util 和java.net 等。标准的 Java 包具有一个层次结构。如同硬盘的目录嵌套一样,也可以使用嵌套层次组织包。所有标准的 Java 包都处于java 和 javax 包层次中使用包的主要原因是确保类名的唯一性。假如两个程序员不约而同地建立了 Employee 类。只要将这些类放置在不同的包中, 就不会产生冲突。事实上,为了保证包名的绝对 唯一性, Sun 公司建议将公司的因特网域名(这显然是独一无二的) 以逆序的形式作为包 名,并且对于不同的项目使用不同的子包。例如, horstmann.com 是本书作者之一注册的域 名。逆序形式为 com.horstmann。这个包还可以被进一步地划分成子包, 如 com.horstmann. corejava。 从编译器的角度来看, 嵌套的包之间没有任何关系。例如,java.utU 包与java.util.jar 包 毫无关系。每一个都拥有独立的类集合。

类的导入

 一个类可以使用所属包中的所有类, 以及其他包中的公有类( public class。) 我们可以采用两种方式访问另一个包中的公有类。第一种方式是在每个类名之前添加完整的包名。例如:
java.tiie.LocalDate today = java.tine.Local Date.now();
这显然很繁琐。更简单且更常用的方式是使用 import 语句。import 语句是一种引用包含在包中的类的简明描述。一旦使用了 import 语句,在使用类时,就不必写出包的全名了。可以使用 import 语句导人一个特定的类或者整个包。import 语句应该位于源文件的顶部(但位于 package 语句的后面)。例如, 可以使用下面这条语句导人 java.util 包中所有的类。
import java.util .*;
然后, 就可以使用
LocalDate today = Local Date.now();
而无须在前面加上包前缀。还可以导人一个包中的特定类:
import java.time.LocalDate;
java.time.* 的语法比较简单,对代码的大小也没有任何负面影响。

但是, 需要注意的是, 只能使用星号(*) 导入一个包, 而不能使用 import java.* 或
import java.*.* 导入以 java 为前缀的所有包。

在大多数情况下, 只导入所需的包, 并不必过多地理睬它们。但在发生命名冲突的时候,就不能不注意包的名字了。例如,java.util 和 java.sql 包都有日期( Date) 类。如果在程
序中导入了这两个包:
import java.util .*;
import java.sql .*;
在程序使用 Date 类的时候, 就会出现一个编译错误:
Date today; // Error java.util .Date or java.sql .Date?
此时编译器无法确定程序使用的是哪一个 Date 类。可以采用增加一个特定的 import 语句来解决这个问题:
import java.util .*;
import java.sql .*;
import java.util .Date;
如果这两个 Date 类都需要使用,又该怎么办呢? 答案是,在每个类名的前面加上完整的
包名。
java.util .Date deadline = new java.util .Date();
java.sql .Date today = new java.sql .Date(...);
在包中定位类是编译器 (compiler) 的工作。类文件中的字节码肯定使用完整的包名来引用其他类。

静态导入 

import 语句不仅可以导人类,还增加了导人静态方法和静态域的功能。
例如,如果在源文件的顶部, 添加一条指令:
import static java.lang.System.*;
就可以使用 System 类的静态方法和静态域,而不必加类名前缀:
out.println("Goodbye, World!"); // i.e., System.out
exit(0); //i.e., System.exit
另外,还可以导入特定的方法或域:
import static java.lang.System.out;
实际上,是否有更多的程序员采用 System.out 或 System.exit 的简写形式,似乎是一件值
得怀疑的事情。这种编写形式不利于代码的清晰度。不过,
sqrt(pow(x, 2) + pow(y, 2))
看起来比
Math.sqrt(Math.pow(x, 2) + Math.pow(y, 2))
清晰得多 。

将类放入包中

想将一个类放人包中, 就必须将包的名字放在源文件的开头,包中定义类的代码之前。例如,程序清单 4-7中的文件 Employee.java 开头是这样的:
package com.horstiann.corejava;
public class Employee
{
}
如果没有在源文件中放置 package 语句, 这个源文件中的类就被放置在一个默认包
( defaulf package ) 中。默认包是一个没有名字的包。例如,com.horstmann.corejava 包
中的所有源文件应该被放置在子目录 com/horstmann/corejava ( Windows 中 com\horstmann\
corejava) 中。编译器将类文件也放在相同的目录结构中。

 

以上这种情况下,仍然要从基目录编译和运行类,即包含 com 目录:
javac com/myconipany/Payrol1App.java
java com.mycompany.PayrollApp

包作用域

前面已经接触过访问修饰符 public 和 private。标记为 public 的部分可以被任意的类使用;标记为 private 的部分只能被定义它们的类使用。如果没有指定 public 或 private , 这 个 部 分(类、方法或变量)可以被同一个包中的所有方法访问。 

4.8类路径

更新中...

4.9文档注释

更新中... 

4.10类设计技巧 

1. 一定要保证数据私有
这是最重要的;绝对不要破坏封装性。有时候, 需要编写一个访问器方法或更改器方法,但是好还是保持实例域的私有性。很多惨痛的经验告诉我们, 数据的表示形式很可能会改变, 但它们的使用方式却不会经常发生变化。当数据保持私有时, 它们的表示形式的变化不会对类的使用者产生影响, 即使出现 bug 也易于检测。
2. 一定要对数据初始化
Java 不对局部变量进行初始化, 但是会对对象的实例域进行初始化。最好不要依赖于系统的默认值,而是应该显式地初始化所有的数据, 具体的初始化方式可以是提供默认值, 也可以是在所有构造器中设置默认值。
3. 不要在类中使用过多的基本类型
就是说,用其他的类代替多个相关的基本类型的使用。这样会使类更加易于理解且易于修改。例如, 用一个称为 Address 的新的类替换一个 Customer 类中以下的实例域:
private String street;
private String city;
private String state;
private int zip;
这样, 可以很容易处理地址的变化, 例如, 需要增加对国际地址的处理。
4. 不是所有的域都需要独立的域访问器和域更改器
或许, 需要获得或设置雇员的薪金。 而一旦构造了雇员对象, 就应该禁止更改雇用日
期,并且在对象中,常常包含一些不希望别人获得或设置的实例域, 例如, 在 Address 类中,
存放州缩写的数组。
5.将职责过多的类进行分解
这样说似乎有点含糊不清, 究竟多少算是“ 过多” ? 每个人的看法不同。但是,如果明显地可以将一个复杂的类分解成两个更为简单的类,就应该将其分解(但另一方面,也不要走极端。设计 10 个类,每个类只有一个方法,显然有些矫枉过正了)。
6. 类名和方法名要能够体现它们的职责
与变量应该有一个能够反映其含义的名字一样, 类也应该如此(在标准类库中, 也存在着一些含义不明确的例子,如:Date 类实际上是一个用于描述时间的类)。命名类名的良好习惯是采用一个名词(Order )、 前面有形容词修饰的名词( RushOrder )或动名词(有“ -ing” 后缀)修饰名词(例如, BillingAddress)。对于方法来说,习惯是访问器方法用小写 get 开头 ( getSalary ), 更改器方法用小写的 set 开头(setSalary )
7.优先使用不可变的类
LocalDate 类以及 java.time 包中的其他类是不可变的—没有方法能修改对象的状态。类似 plusDays 的方法并不是更改对象,而是返回状态已修改的新对象。更改对象的问题在于, 如果多个线程试图同时更新一个对象,就会发生并发更改。其结果是不可预料的。如果类是不可变的,就可以安全地在多个线程间共享其对象。因此, 要尽可能让类是不可变的, 这是一个很好的想法。对于表示值的类, 如一个字符串或一个时间点,这尤其容易。计算会生成新值, 而不是更新原来的值。当然,并不是所有类都应当是不可变的。如果员工加薪时让 raiseSalary 方法返回一个新
的 Employee 对象, 这会很奇怪。 

  • 12
    点赞
  • 17
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值