Java 对象与类

类和对象

定义类的语法为:

1
[修饰符] class 类名 {}

修饰符可以是 publicfinalabstract

public:所有包中的类都可以使用该类。

defalut:该类只能被同一个包中的类使用。

final:该类不可继承。

abstract 表明该类是一个抽象类。

如果什么修饰符都不使用,那么该类只能被同一个包中的类使用。

此处需要区分【外部类】和【内部类】

定义成员变量的语法格式如下:

1
[修饰符] 类型 成员变量名 [=默认值]

修饰符可以省略,也可以是 publicprotectedprivatestaticfinal。其中 publicprotectedprivate 只能出现一个,可以与 staticfinal 组合起来修饰成员变量。

public

private

protected

default

定义方法的语法格式如下:

1
[修饰符] 方法返回值类型 方法名(形参列表){}

public

private:该方法只能被

protected:该方法既能被同一个包中的其他类访问,也可以被不同包中的子类访问。

default:该方法只能被同一个包中的其他类访问。

static 修饰的成员不能访问没有 static 修饰的成员。但是非 static 方法可以访问 static 成员变量。

访问控制级别

privatedefaultprotectedpublic

default 指没有指定任何访问控制符。

关于类名与源代码文件名

如果一个 Java 源文件中的所有类都没有使用 public 修饰,那么该源文件可以使用任意合法的文件名。但是只要 Java 源文件中定义了一个 public 修饰的类,则这个源文件的文件名必须和使用 public 修饰的类的名称相同。

方法

方法的参数传递机制

Java 的方法是采用值传递的方式的。也就是说方法中的形参实际上是实参的拷贝。如果形参是原生类型,那么形参就直接是实参的拷贝。如果形参是引用类型,那么形参就是实参的引用的拷贝。

形参个数可变

从 JDK 1.5 开始,Java 支持个数不确定的形参。定义方法是在最后一个形参的类型后边加上三个点(...)。

例如:

1
2
3
4
5
6
7
8
9
10
11
class Varargs {
public static void test(String... books) {
for (String book : books) {
System.out.println(book);
}
}

public static void main(String[] args) {
test("book1", "book2", "book3");
}
}

注意:一个方法只能包含一个数量可变的形参,而且如果有多个形参的时候,数量可变的形参必须位于形参列表的最后一个。

方法重载(Overload)

重载方法的定义是方法名相同、形参列表不同。和返回值类型、修饰符没有关系。

方法重写(Override)

自类重写父类的方法。需要满足“两同两小一大”:

  • 方法名相同
  • 形参列表相同
  • 子类方法的返回值应该比父类方法的返回值类型更小或相等
  • 字类方法声明抛出的异常应该比父类方法生命抛出的异常更小或相等
  • 子类方法的访问权限应该比父类方法的访问权限更大或相等。

java - Multilevel Inheritance, trying to access super classes - Stack Overflow

多态

对于方法,总是会调用运行时类型的方法。

对于引用变量,系统总是试图访问编译时类型所定义的成员变量,而不是运行时类型所定义的成员变量。

面向对象的一些基本概念

对象中的数据称为 实例域(instance field)

类之间的关系

在类之间,最常见的关系有

  • 依赖(Dependency):“uses-a”
  • 聚合(Aggregation):“has-a”
  • 继承(Inheritance):“is-a”

使用Java语言提供的预定义类

new 操作符返回的是一个引用。

对象和对象变量的区别

对象变量只是一个变量。对象是类的一个示例。一个对象变量引用一个对象。

可以显式地将对象变量设置为 null,表示对象变量没有引用任何对象。注意,如果只定义局部变量而不显式地初始化,那么局部变量是不会自动初始化为 null的。所以如果想设置对象变量为不引用任何对象,一定要显式地设置为 null

Java类库中的 LocalDate

Date 类和 LocalDate 类的区别

Date 类的对象有一个状态,也就是一个特定的精确到毫秒的时间点。

LocalDate 则只精确到日。

对于很多常用的情况,像日历的应用,只需要精确到日就可以了,这个时候使用 LocalDate 更加方便一些。

LocalDate 的使用

不要使用构造器来构造 LocalDate 类的对象,应该使用 静态工厂方法(factory method) 来代替构造器。

调用 LocalDate.now() 会构造一个新对象,表示构造这个对象时的日期。

使用示例:

1
2
3
4
5
6
7
8
LocalDate newYearsEve = LocalDate.of(1999,12,31); // 构造一个表示特定日期的对象

// 获取年月日的值
int year = newYearsEve.getYear();
int month = newYearsEve.getMonthValue();
int day = newYearsEve.getDayOfMonth();

LocalDate aThousandDaysLater = newYearsEve.plusDays(1000); // 计算机1000天后的日期

更改器方法与访问器方法

上一节的 newYearsEve.plusDays(1000) 调用,newYearsEve 本身不发生变化,plusDays() 方法会生成一个新的 LocalDate 对象作为返回值。String 类的 toUpperCase() 方法也不会对对象本身进行更改而是返回一个将字符串大写的新字符串。这种只访问对象不修改对象的方法叫做 访问器方法(accessor method)

一个对象调用了自己的一个方法之后,如果对象本身发生改变,那么称这种方法为 更改器方法(mutator method)

用户自定义类

一个Java源文件的文件名必须与 public 类的名字相同,在一个源文件中,只能有一个公有类,但可以有任意数目的非公有类。如果文件中没有 public 类,文件命名不受约束。

构造器

  • 构造器与类同名
  • 一个类可以有多个构造器
  • 构造器没有返回值
  • 对于一个对象而言,只能在创建对象的时候使用 new 操作符调用一次构造器,对象创建完成之后就不能再调用构造器了。

隐式参数和显式参数

假设有一个方法调用为:

1
employee1.raiseSalary(5);

这里,我们看到括号中只有一个参数,这种括号中的参数叫做 显式参数(explicit parameter)。其实调用这个方法的对象本身也作为方法的一个参数,这个对象叫做方法的 隐式参数(implicit parameter)

在一个方法定义的内部,可以使用 this 关键字来指代 隐式参数

封装

注意不要编写返回引用可变对象的访问器方法。

考虑以下代码:

1
2
3
4
5
6
7
8
class Employee{
private Date hireDay;
...
public Date getHireDay()
{
return hireDay;
}
}

hireDay 是一个私有属性,而且 Date 类型的对象还是可变的。在一个方法中直接把一个私有属性的引用作为返回值,会导致外部通过这个引用可以直接修改对象的私有属性,违反了封装的要求。

如果想要返回一个私有的可变对象,应该首先对其进行 克隆(clone)

示例代码:

1
2
3
4
5
6
7
8
9
class Employee
{
...
public Date getHireDay()
{
return (Date) hireDay.clone(); // 调用clone()方法
}
...
}

基于类的访问权限

根据前边的知识我们知道一个方法如果被调用的话可以访问它的隐式参数的私有数据。但实际上,一个方法对它所属类的所有对象的私有数据都有访问权限。

考虑这个方法调用 harry.equals(boss)equals() 方法不仅访问了 harry 的私有方法,同时也访问了 boss 的私有方法,就是因为一个方法它所在类的任何一个对象的私有域。

final 实例域

可以将实例域定义为 final,同时,必须保证构造器在构造对象的时候必须对 final 实例域进行赋值,且在后面的操作中不能再对 final 的实例域进行修改。

final 修饰符大都应用于 基本(primitive) 类型,或 不可变(immutable) 类的域。

静态域与静态方法

静态域

可以通过 static 修饰符将一个域标记为 静态域,一个静态域属于一个类,不属于某一个具体的对象,即使这个类没有一个对象,这个静态域也存在。这个类的所有对象共享这个 静态域

静态常量

Math.PI 就是一个静态常量

1
2
3
4
5
6
public class Math
{
...
public static final double PI = 3.14159265358979323846;
...
}

System.out 也是一个静态常量。它在 System 类中声明:

1
2
3
4
5
6
public class System
{
...
public static final PrintStream out = ...;
...
}
  • 特殊情况:如果查看以下 System 类,就会发现有一个 setOut() 方法,它可以将System.out 设置为不同的流。读者可能会感到奇怪,为什么这个方法可以修改 final 变量的值。原因在于,setOut() 是一个 native method ,不是使用Java语言实现的,native method 可以绕过Java语言的存取控制机制。这是一种非常特殊的写法,自己编写程序的时候不要这样做。

静态方法

静态方法是一种不能向对象实施操作的方法。例如,Math 类的 pow 方法就是一个静态方法。也就是说,静态方法没有 隐式参数

静态方法不能访问对象的 实例域。但是可以访问类的 静态变量

即可以使用类名调用静态方法,也可以使用对象调用静态方法,但是通常情况下都使用类名调用静态方法,这样不容易使人混淆。

工厂方法

静态方法还有另外一个常见的用途,就是作为工厂方法。之前的 LocalDate.now()LocalDate.of() 就是工厂方法。

另外,NumberFormat 类如下使用工厂方法生成不同风格的格式化对象:

1
2
3
4
5
6
// Currency对象和Percent对象都是Decimal类型的对象,都是NumberFormat的子类。
NumberFormat currencyFormatter = NumberFormat.getCurrencyInstance();
NumberFormat percentFormatter = NumberFormat.getPercentInstance();
double x = 0.1;
System.out.println(currencyFormatter.format(x)); // prints $0.10
System.out.println(percentFormatter.format(x)); // prints 10%

为什么 NumberFormat 类不利用构造器完成这些操作呢?这主要有两个原因:

  • 构造器只能得到一种类型的对象,但是这里希望得到两种不同的对象

  • NumberFormat 的工厂方法返回一个 DecimalFormat 类型的对象,这个类型是 NumberFormat 的子类。

方法参数

方法调用主要分为两种类型:传值调用(Call by value)引用调用(Call by reference)

Java语言总是采用传值调用方式。也就是说方法得到的是所有参数值的一个拷贝,方法不能修改传递给它的任何参数变量的内容。

我们来看一种情况,对于

1
2
3
4
5
6
class Employee{
public static void tripleSalary(Employee x) //works
{
x.raiseSalary();
}
}

如果调用

1
2
harry = new Employee(...);
tripleSalary(harry);

这样确实可以改变 harry 的状态,这是否意味着Java也有引用调用的情况?

不是这样的,在这种情况下,Java仍然是传值调用,不过传递的是 对象引用的拷贝,这个拷贝和原来的对象引用指向相同的对象。

对象构造

重载 Overloading

一个类可以有多个同名的方法,只要这些方法具有不同的 方法签名 就可以。也就是说同名的方法只要没有顺序和类型完全相同的 显式参数 就可以。

Java允许重载任何方法。

默认域初始化

如果在构造器中没有显式地为域赋初值,那么就会被自动地赋为默认值:数值为 0 ,布尔值为 false,对象引用为 null

但非常不建议这么做,这不是一种良好的编程习惯。

无参数的构造器

如果在编写一个类的时候没有编写构造器,那么系统就会提供一个无参数构造器,这个构造器将所有的实例域设置为默认值。

但是只要你编写了自己的有参数构造器,系统将不会再提供无参数构造器,此时如果再尝试调用无参数构造器就会报错。

显式域初始化

可以在类定义的时候直接为实例域赋值。这样,当调用构造器的时候会先执行这些类定义中的赋值语句。

当一个类的所有构造器都希望把相同的值赋给某个特定的实例域时,这种方法可以起到精简代码的作用。例如:

1
2
3
4
5
6
7
8
9
10
11
12
class Employee
{
private static int nextId;
private in id = assignId();
...
private static int assignId()
{
int r = nextId;
nextId++;
return r;
}
}

参数名

把一个方法的参数变量的名字和同样的实例域的名字设置为相同的,这是很常见的操作。这时在方法中使用 this.xxx 来引用实例域。

比如,假设 Employee 类有 namesalary 两个实例域。则:

1
2
3
4
5
public Employee(String name, double salary)
{
this.name = name;
this.salary = salary;
}

这种形式的代码清晰易懂。

调用另一个构造器

在类的一个构造器中可以使用 this 关键字来调用另外一个构造器。比如:

1
2
3
4
5
6
public Employee(double s)
{
//calls Employee(String, double)
this("Employee #" + nextId, s);
nextId++;
}

当调用 new Employee(60000) 时,Employee(double) 构造器将调用 Employee(String, double)构造器。

采用这种方式使用 this 关键字非常有用,这样对公共的构造器代码部分只需编写一次。

初始化块

可以使用 初始化块(initialization block) 来对数据域进行初始化。

无论使用哪个构造器构造对象,都会先执行初始化块,然后才执行构造器的主体部分。不管初始化块在源代码中是在构造器之前还是之后,都会先执行初始化块。

之所以初始化块会被先执行,原理是,源代码编译之后,其实是把所有初始化块中的内容统一插入到了所有构造器中的最前边,通过反编译可以看到。但是如果在一个构造器中调用了另外一个构造器,那么该构造器中最前边不会插入初始化块中的代码。

示例1:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Demo {

{
System.out.println("Initializer block 1");
}

public Demo() {
System.out.println("Constructor 1");
}

{
System.out.println("Initializer block 2");
}
}

创建上边这个对象时,控制台输出结果是:

1
2
3
Initializer block 1
Initializer block 2
Constructor 1

编译成 class 文件之后再反编译,会发现该类实际上是:

1
2
3
4
5
6
7
class Demo {
public Demo() {
System.out.println("Initializer block 1");
System.out.println("Initializer block 2");
System.out.println("Constructor 1");
}
}

示例2:多构造器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Demo {

{
System.out.println("Initializer block 1");
}

public Demo() {
System.out.println("Constructor 1");
}

public Demo(String str) {
this();
System.out.println("Constructor 2");
}

{
System.out.println("Initializer block 2");
}

public static void main(String[] args) {
new Demo("");
}
}

执行上边代码中的 main 方法,结果是:

1
2
3
4
Initializer block 1
Initializer block 2
Constructor 1
Constructor 2

编译之后再进行反编译,结果是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Demo {
public Demo() {
System.out.println("Initializer block 1");
System.out.println("Initializer block 2");
System.out.println("Constructor 1");
}

public Demo(String str) {
this();
System.out.println("Constructor 2");
}

public static void main(String[] args) {
new Demo("");
}
}

静态初始化块

如果初始化块使用了 static 修饰符,则这个初始化块就变成了静态初始化块,也成为类初始化块。普通初始化块负责对对象进行初始化,类初始化块负责对类进行初始化。静态初始化块将在类初始化阶段执行,而不是在创建对象的时候执行,因此静态初始化块总是比普通初始化块先执行。静态初始化块是类相关的,用于对整个类进行初始化处理,通常用于对类变量执行初始化处理,静态初始化块不能对实例变量进行初始化处理。

赋值顺序问题

1
2
3
4
5
6
7
8
9
10
11
class StaticInitTest {
static {
a = 2;
}

public static int a = 1;

public static void main(String[] args) {
System.out.println(StaticInitTest.a);
}
}

运行上边这个类的 main 方法会输出什么呢?

答:会输出1。

为什么呢?因为类初始化块和声明类变量时执行初始值的语句都是在类的初始化阶段执行的,它们的执行顺序与它们在源代码中出现的顺序相同。

对象析构与 finalize 方法

在 C++ 语言中存在 析构方法,在对象生命周期结束的时候会调用析构方法释放对象占用的存储空间。Java不支持析构器,但是支持为类添加 finalize 方法,这个方法将在垃圾回收器清楚对象之前调用。但是在实际的应用当中,不要依赖这个方法回收任何比较短缺的资源,因为很难知道这个方法什么时候会被调用,也就是说垃圾回收器执行的时间是不确定的。

Java 允许使用 包(package) 将对象组织起来。

使用包的主要原因是为了确保类名的唯一性。例如有可能两个名字相同但功能不同的类,只要将他们放在不同的包中,通过包名进行引用,就可以保证他们之间不产生冲突。

为了保证包名的绝对唯一性,Sun公司建议将公司的互联网域名以逆序的形式作为包名。例如 alibaba.com 开发的类就可以用 com.alibaba 作为包名。

包可以被进一步划分成子包,但是从编译器的角度来看,嵌套的包之间没有任何关系。例如 java.util 包与 java.util.jar 包毫无关系,每一个都拥有独立的类集合。

类的导入 import 语句的使用

Java中的import机制类似于C++中的 命名空间(namespace)

静态导入

import 语句不仅可以导入类,还增加了导入静态方法和静态域的功能。

例如,如果在源文件的顶部添加一条指令 import static java.lang.System.*;,就可以使用 System 类的静态方法和静态域,而不必加类名前缀。示例:

1
out.println("Hello, world!") // 等同于 System.out.println

但是这种使用方法很少,不过

sqrt(pow(x, 2)) + pow(y, 2)

要比

Math.sqrt(Math.pow(x, 2)) + Math.pow(y, 2)

更清晰。

包信息和文件物理存储必须匹配

编译器在编译源文件的时候不检查目录结构。可以正常编译,但是正常运行的时候,如果包与目录不匹配,虚拟机就找不到类。

所以,务必保证包信息和源代码文件物理存储结构是一致的。

包作用域

对于方法和实例域,标记为 private 的部分只能被定义它们的类使用。如果没有指定 public 或者 private,那么则只能被同一个包中的所有方法访问。

文档注释

包装类

JDK 1.5 中引入了自动装箱(Autoboxing)和自动拆箱(Autounboxing)功能。基本类型和其对应的包装类可以直接相互赋值。

但是需要注意,基本类型之间可以直接使用 == 比较两者是否相等,但是包装类必须使用 equals() 方法来比较。

此处引申一下,Integer 类中缓存了 -128 到 127 之间的数字,所以下边示例 1 中的代码会输出 true,示例 2 中的代码会输出 false。

1
2
3
4
// 示例 1
Integer a = 127;
Integer b = 127;
System.out.println(a == b);
1
2
3
4
// 示例 2
Integer a = 128;
Integer b = 128;
System.out.println(a == b);