Java 基础教程

Java 面向对象

Java 高级教程

Java 笔记

Java 对象和类


类(class)是构造对象的模板或蓝图。可以将类想象成制作小甜饼的切割机,将对象想象为小甜饼。由类构造(construct)对象的过程称为创建类的实例(instance)。

面向对象程序设计

面向对象程序设计(简称 OOP)是当今主流的程序设计范型,它已经取代了 20 世纪 70 年代的“结构化”过程化程序设计开发技术。Java 是完全面向对象的,必须熟悉 OOP 才能够编写 Java 程序。

面向对象的程序是由对象组成的,每个对象包含对用户公开的特定功能部分和隐藏的实现部分。程序中的很多对象来自标准库,还有一些是自定义的。究竟是自己构造对象,还是从外界购买对象完全取决于开发项目的预算和时间。但是,从根本上说,只要对象能够满足要求,就不必关心其功能的具体实现过程。在 OOP 中,不必关心对象的具体实现,只要能够满足用户的需求即可。

用 Java 编写的所有代码都位于某个类的内部:标准的 Java 库提供了几千个类,可以用于用户界面设计、日期、日历和网络程序设计。尽管如此,还是需要在 Java 程序中创建一些自己的类,以便描述应用程序所对应的问题域中的对象。

封装(encapsulation,有时称为数据隐藏)是与对象有关的一个重要概念。从形式上看,封装不过是将数据和行为组合在一个包中,并对对象的使用者隐藏了数据的实现方式。对象中的数据称为实例域(instance field),操纵数据的过程称为方法(method)。对于每个特定的类实例(对象)都有一组特定的实例域值。这些值的集合就是这个对象的当前状态(state)。无论何时,只要向对象发送一个消息,它的状态就有可能发生改变。

实现封装的关键在于绝对不能让类中的方法直接地访问其他类的实例域。程序仅通过对象的方法与对象数据进行交互。封装给对象赋予了“黑盒”特征,这是提高重用性可靠性的关键。这意味着一个类可以全面地改变存储数据的方式,只要仍旧使用同样的方法操作数据,其他对象就不会知道或介意所发生的变化。

此外,传统的过程化程序设计,必须从顶部的 main 函数开始编写程序。在面向对象程序设计时没有所谓的“顶部”。

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

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

依赖(dependence),即“uses-a”关系,是一种最明显的、最常见的关系。如果一个类的方法操纵另一个类的对象,我们就说一个类依赖于另一个类。

应该尽可能地将相互依赖的类减至最少。如果类 A 不知道 B 的存在,它就不会关心 B 的任何改变(这意味着 B 的改变不会导致 A 产生任何 bug)。用软件工程的术语来说,就是让类之间的耦合度最小。

聚合(aggregation),即“has-a”关系,是一种具体且易于理解的关系。聚合关系意味着类 A 的对象包含类 B 的对象。

注释:有些方法学家不喜欢聚合这个概念,而更加喜欢使用“关联”这个术语。从建模的角度看,这是可以理解的。但对于程序员来说,“has-a”显得更加形象。喜欢使用聚合的另一个理由是关联的标准符号不易区分。

继承(inheritance),即“is-a”关系,是一种用于表示特殊与一般关系的。一般而言,如果类 A 扩展类 B,类 A 不但包含从类 B 继承的方法,还会拥有一些额外的功能。

对象

要想使用OOP,—定要清楚对象的三个主要特性:

  • 对象的行为(behavior):可以对对象施加哪些操作,或可以对对象施加哪些方法?
  • 对象的状态(state):当施加那些方法时,对象如何响应?
  • 对象标识(identity):如何辨别具有相同行为与状态的不同对象?

同一个类的所有对象实例,由于支持相同的行为而具有家族式的相似性。对象的行为是用可调用的方法定义的。

此外,每个对象都保存着描述当前特征的信息。这就是对象的状态。对象的状态可能会随着时间而发生改变,但这种改变不会是自发的。对象状态的改变必须通过调用方法实现(如果不经过方法调用就可以改变对象状态,只能说明封装性遭到了破坏)。

但是,对象的状态并不能完全描述一个对象。每个对象都有一个唯一的身份(identity)。

静态域与静态方法

静态域

如果将域定义为 static,每个类中只有一个这样的域。而每一个对象对于所有的实例域却都有自己的一份拷贝。例如,假定需要给每一个雇员赋予唯一的标识码。这里给 Employee 类添加一个实例域 id 和一个静态域 nextld:

class Employee {
    private static int nextId = 1;
    private int id;
}

现在,每一个雇员对象都有一个自己的 id 域,但这个类的所有实例将共享一个 nextld 域。换句话说,如果有 1000 个 Employee 类的对象,则有 1000 个实例域 id。但是,只有一个静态域 nextld。即使没有一个雇员对象,静态域 nextld 也存在。它属于类,而不属于任何独立的对象。

静态常量

静态变量使用得比较少,但静态常量却使用得比较多。例如,在 Math 类中定义了一个静态常量:

public final class Math {
    public static final double PI = 3.14159265358979323846;
}

在程序中,可以采用 Math.PI 的形式获得这个常量。

如果关键字 static 被省略,PI 就变成了 Math 类的一个实例域。需要通过 Math 类的对象访问 PI,并且每一个 Math 对象都有它自己的一份 PI 拷贝。

静态方法

静态方法是一种不能向对象实施操作的方法。例如,Math 类的 pow 方法就是一个静态方法。

public static double pow(double a, double b) {
    return StrictMath.pow(a, b); // default impl. delegates to StrictMath
}

可以认为静态方法是没有 this 参数的方法(在一个非静态的方法中,this 参数表示这个方法的隐式参数)。

在下面 2 种情况下使用静态方法:

  • 一个方法不需要访问对象状态,其所需参数都是通过显式参数提供(例如:Math.pow)。
  • 一个方法只需要访问类的静态域。

工厂方法

静态方法还有另外一种常见的用途。类似 LocalDate 和 NumberFormat 的类使用静态工厂方法(factory method)来构造对象。

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

NumberFormat currencyFormatter = NumberFormat.getCurrencyInstance();
NumberFormat percentFormatter = NumberFormat.getPercentInstance();
double x = 0.1;
System.out.println(currencyFormatter.format(x)); // print ¥0.10
System.out.println(percentFormatter.format(x)); // print 10%

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

  • 无法命名构造器。构造器的名字必须与类名相同。但是,这里希望将得到的货币实例和百分比实例采用不用的名字。
  • 当使用构造器时,无法改变所构造的对象类型。而 Factory 方法将返回一个 DecimalFormat 类对象,这是 NumberFormat 的子类。

方法参数

按值调用(call by value)表示方法接收的是调用者提供的值。而按引用调用(call by reference)表示方法接收的是调用者提供的变量地址。一个方法可以修改传递引用所对应的变量值,而不能修改传递值调用所对应的变量值。“按......调用”(call by)是一个标准的计算机科学术语,它用来描述各种程序设计语言(不只是 Java)中方法参数的传递方式(事实上,以前还有按名调用(call by name),Algo丨程序设计语言是最古老的高级程序设计语言之一,它使用的就是这种参数传递方式。不过,对于今天,这种传递方式已经成为历史)。

Java 程序设计语言总是采用按值调用。也就是说,方法得到的是所有参数值的一个拷贝,特别是,方法不能修改传递给它的任何参数变量的内容。关于值传递,详情请看 java 调用方法时,参数传递的是引用还是值

下面总结一下 Java 中方法参数的使用情况:

  • 一个方法不能修改一个基本数据类型的参数(即数值型或布尔型)。
  • 一个方法可以改变一个对象参数的状态。
  • 一个方法不能让对象参数引用一个新的对象。

对象构造

重载

有些类有多个构造器。例如,可以如下构造一个空的 StringBuilder 对象:

StringBuilder sb = new StringBuilder();
StringBuilder todoList = new StringBuilder("To do:\n");

这种特征叫做重载(overloading)。如果多个方法(比如,StringBuilder 构造器方法)有相同的名字、不同的参数,便产生了重载。编译器必须挑选出具体执行哪个方法,它通过用各个方法给出的参数类型与特定方法调用所使用的值类型进行匹配来挑选出相应的方法。如果编译器找不到匹配的参数,就会产生编译时错误,因为根本不存在匹配,或者没有一个比其他的更好。(这个过程被称为重载解析(overloading resolution)。)

注释:Java 允许重载任何方法,而不只是构造器方法。因此,要完整地描述一个方法,需要指出方法名以及参数类型。这叫做方法的签名(signature)。

默认域初始化

如果在构造器中没有显式地给域赋予初值,那么就会被自动地赋为默认值:数值为 0、布尔值为 false、对象引用为 null。然而,只有缺少程序设计经验的人才会这样做。确实,如果不明确地对域进行初始化,就会影响程序代码的可读性。

注释:这是域与局部变量的主要不同点。必须明确地初始化方法中的局部变量。但是,如果没有初始化类中的域,将会被自动初始化为默认值(0、false 或 null)。

无参数的构造器

很多类都包含一个无参数的构造函数,对象由无参数构造函数创建时,其状态会设置为适当的默认值。

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

如果类中提供了至少一个构造器,但是没有提供无参数的构造器,则在构造对象时如果 没有提供参数就会被视为不合法。

显式域初始化

通过重载类的构造器方法,可以采用多种形式设置类的实例域的初始状态。确保不管怎样调用构造器,每个实例域都可以被设置为一个有意义的初值,这是一种很好的设计习惯。

可以在类定义中,直接将一个值赋给任何域。

对象析构与 finalize 方法

有些面向对象的程序设计语言,特别是 C++,有显式的析构器方法,其中放置一些当对象不再使用时需要执行的清理代码。在析构器中,最常见的操作是回收分配给对象的存储空间。由于 Java 有自动的垃圾回收器,不需要人工回收内存,所以 Java 不支持析构器。

当然,某些对象使用了内存之外的其他资源,例如,文件或使用了系统资源的另一个对象的句柄。在这种情况下,当资源不再需要时,将其回收和再利用将显得十分重要。

可以为任何一个类添加 finalize 方法。finalize 方法将在垃圾回收器清除对象之前调用。在实际应用中,不要依赖于使用 finalize 方法回收任何短缺的资源,这是因为很难知道这个方法什么时候才能够调用。

如果某个资源需要在使用完毕后立刻被关闭,那么就需要由人工来管理。对象用完时,可以应用一个 close 方法来完成相应的清理操作。