





所有 Java 程序都使用对象,对象的类型由类或接口定义。每个 Java 程序都定义成类,而复杂的程序会定义很多类和接口。
对象导论
抽象过程
面向对象语言的五个基本特性,这些特性表现了一个纯粹的面向对象的程序设计方式:
- 万物皆对象。将对象视为奇特的变量,它可以储存数据,除此之外,你还可以要求它在自身上执行操作。理论上讲,你可以抽取待求解决的任何概念化构件(狗、建筑物、服务等),将其表示为程序中的对象。
- 程序是对象的集合,它们通过发送消息来告知彼此要做的。要想请求一个对象,就必须对该对象发送一条消息。更具体地说,可以把消息想象为对某个特定对象的方法的调用请求。
- 每个对象都有自己的由其他对象所构成的储存。换句话说,可以通过创建包含现有对象的包的方式来创建新类型的对象。因此,可以在程序中构建复杂的体系,同时将其复杂性隐藏在对象的简单性背后。
- 每个对象都拥有其类型。按照通用的说法,“每个对象都是某个类(class)的一个实例(instance)”,这里“类”就是“类型”的同义词。每个类最重要的区别于其他类型的特征就是“可以发送什么样的消息给它”。
- 某一个特定类型的所有对象都可以接受同样的消息。可替代性是OOP中最强有力的概念之一
对象具有状态、行为和标识。这意味着每一个对象都可以拥有内部类数据(它们给出了该对象的状态)和方法(它们产生行为),并且每一个对象都可以唯一地与其他对象区分开来,具体说来,就是每一个对象在内存中都有一个唯一的地址。
面向对象程序设计概述
面向对象程序设计(object-oriented programming,OOP)是当今主流的程序设计范型,它取代了20世纪70年代的“结构化”或过程式编程技术。由于Java是面向对象的所以你必须熟悉OOP才能够很好地使用Java。
面向对象的程序是由对象组成的,每个对象包含用户公开的特定功能部分和隐藏的实现部分。程序中很多兑现来自标准库,还有一些是自定义的。究竟是自己构造对象,还是从外界购买对象完全取决于对开发项目的预算和时间。但是,从根本上说,只有对象能够满足要求,就不必关心其功能到底是如何实现的。
传统的结构化程序设计通过设计一系列的过程(即算法)来求解问题。一旦确定了这些过程,就要开始考虑储存数据的适当方式。这就是Pascal语言的设计者Niklaus Wirth将其著作命名为《算法+数据结构=程序》的原因。需要注意的是,在Wirth的这个书名中,算法是第一位的,数据结构是第二位的,这就明确地表述了程序员的工作方式。首先要确定如何操作数据,然后再决定如何组织数据的结构,一边于操作数据。而OOP却调换了这个次序,将数据放在第一位,然后再考虑操作数据的算法。
对于一些规模较小的问题,将其分解为过程开发比较理想。面向对象更加适合解决规模较大的问题。
类
类(class)是构造对象的模板或蓝图。我们可以将类想象成制作小甜饼的模具,将对象想象为小甜饼。由类构造(construct)对象的过程称为创建类的实例(instance)
用Java编写的所有代码都位于某个类里面。标准Java库中提供了几千个类,可用于各种目的,如用户界面设计、日期、日历和网络编程。尽管如此,在Java中还是需要创建一些自己的类,以便描述你的应用程序所对应的问题领域中的对象。
封装(encapsulation,有时称为数据隐藏)是处理对象的一个重要概念。从形式上看,封装就是将数据和行为组合在一个包中,并对对象的使用者隐藏具体的实现方式。对象中的数据为实例字段(instance field),操作数据的过程称为方法(method)。作为一个类的实例,特定对象都有一组特定的实例字段值。这些值的集合就是这个对象的当前状态(state)。无论何时,只要在对象上调用一个方法,它的状态就有可能发生改变。
实现封装的关键在于,绝对不能让类中的方法直接访问其他类的实例字段,程序只能通过对象的方法与对象的数据进行交互。封装给对象赋予了“黑盒”特征,这是提高重用性合可靠性的关键。这意味着一个类可以完全改变存储数据的方式,只要仍旧使用同样的方法操作数据,其他对象就不会知道也不用关系这个类所发生的变化。
OOP的另一个原则会让用户自定义Java类变得更为容易,这就是:可以通过扩展其他类来构建新类。事实上,在Java中,所有的类都源自一个“神通广大的超类”,它就是Object。所有其他类都扩展自这个Object类
在扩展一个已有的类时,这个扩展后的新类具有被扩展的类的全部属性合方法。你只需要在新类中提供适用于这个新类的新方法和数据字段就可以了。通过扩展一个类来建立另外一个类的过程称为继承(inheritance)。
对象
要想使用OOP,一定要清楚对象的三个主要特性:
- 对象的行为(behavior):可以对对象完成哪些操作,或者可以对对象应用哪些方法
- 对象的状态(state):当调用哪些方法时,对象会如何响应
- 对象的标识(identity):如何区分具有相同行为与状态的不同对象
识别类
类之间的关系
使用预定义类
对象与对象变量
要想使用对象,首先必须构造对象,并指定其初始状态。然后对对象应用方法。
在Java程序设计语言中,要使用构造器(constructor,或称为构造函数)构造新实例。构造器是一种特殊的方法,用来构造并初始化对象。下面看一个例子。在标准Java库中包含一个Date类。它的对象可以描述一个时间点。
构造器的名字应该与类名相同。因此Date类的构造器名为Date。想要构造一个Date对象,需要在构造器前面加上new操作符,如下所示:
1 | new Date() |
这个表达式构造了一个新对象。这个对象被初始化为当前的日期和时间。
如果需要的话,也可以将这个对象传递给一个方法:
1 | System.out.println(new Date()); |
或者,也可以对刚刚创建的对象应用一个方法。Date类中有一个toString方法。这个方法将返回日期的字符串描述。下面的语句可以说明如何对新构造的Date对象应用toString方法。
1 | String s = new Date().toString(); |
在着两个例子中,构造的对象仅使用了一次。通常你会希望构造的对象可以多次使用,因此,需要将对象存放在一个变量中:
1 | Date birthday = new Date(); |
在对象与对象之间存在着一个重要的区别。例如,一下语句:
1 | Date deadline;// deadline doesn't refer to any object |
定义了一个对象变量deadline,它可以引用Date类型的对象。但是,一定要认识到:变量deadline不是一个对象,而且实际上它也没有引用任何对象。此时还不能在这个变量上使用任何Date方法。必须首先初始化变量deadline,这里有两个选择。
可以初始化这个变量,让它引用一个新的构造的对象:
1 | deadline = new Date(); |
也可以设置这个变量,让它引用一个已有的对象:
1 | deadline = birthday; |
现在,这两个变量都引用同一个对象,要认识到重要的一点:对象变量并没有实际包含一个对象,它只是引用一个对象。
在Java中,任何对象变量的值都是对存储在另外一个地方的某个对象的引用,下面的语句:
1 | Date deadline = new Date(); |
有两个部分。表达式new Date()构造了一个Date类型的对象,它的
P98
类简介
类是 Java 程序最基本的元素结构。编写 Java 代码不可能不定义类。所有 Java 语句都在类中,而且所有方法都在类中实现。
面向对象的基本概念
类:类由一些保存值的数据字段和处理这些值的方法组成。类定义一种新的引用类型。
对象:对象是类的实例。对象一般通过实例化类创建,方法是使用 new 关键字并调用构造方法。
一个类的定义包含一个签名和一个主体。类的签名定义类的名称,可能还会指定其他重要信息。类的主体是一些放在花括号里的成员。类的成员一般包含字段和方法,也可以包含构造方法、初始化程序和嵌套类型。成员可以是静态的,也可以是非静态的。静态成员属于类本身,而非静态成员关联在类的实例上。
常见的成员有四种:类字段、类方法、实例字段和实例方法。Java 的主要工作就是与这些成员交互。
类的签名可能会声明它扩展自其他类。被扩展的类叫作超类,扩展其他类的类叫作子类。子类继承超类的成员,而且可以声明新成员,或者使用新的实现覆盖继承的方法。
类的成员可以使用访问修饰符 public、protected 或 private。这些修饰符指定成员在使用方和子类中是否可见以及能否访问。类通过这种方式控制对非公开 API 成员的访问。隐藏成员是一种面向对象设计技术,叫作数据封装(data encapsulation)
其他引用类型
类的签名可能还会声明类实现了一个或多个接口。接口是一种类似于类的引用类型,其中定义了方法签名,但一般没有实现方法的方法主体。
不过,从 Java 8 开始,接口可以使用关键字 default 指明其中的方法是可选的。如果方法是可选的,接口文件必须包含默认的实现(因此才选用 default 这个关键词);所有实现这个接口的类,如果没有实现可选的方法,就使用接口中默认的实现。
实现接口的类必须为接口的非默认方法提供主体。实现某个接口的类的实例,也是这个接口类型的实例。
类和接口是 Java 定义的五种基本引用类型中最重要的两个。另外三个基本引用类型是数组、枚举类型和注解类型(通常直接叫“注解”)。枚举是特殊的类,注解是特殊的接口。
定义类的句法
最简单的类定义方式是在关键字 class 后面放上类的名称,然后在花括号中放一些类的成员。class 关键字前面可以放修饰符关键字或注解。
- 如果类扩展其他类,类名后面要加上
extends关键字和要扩展的类名。 - 如果类实现一个或多个接口,类名或
extends子句之后要加上implements关键字和用逗号分隔的接口名。
例如:
1 | public class Integer extends Number implements Serializable, Comparable { |
定义泛型类时还可以指定类型参数和通配符。
类声明可以包含修饰符关键字。除访问控制修饰符(public、protected 等)之外,还可以使用:
abstract:abstract修饰的类未完全实现,不能实例化。只要类中有abstract修饰的方法,这个
类就必须使用abstract声明。final:final修饰符指明这个类无法被扩展。类不能同时声明为abstract和final。strictfp:如果类声明为strictfp,那么其中所有的方法都声明为strictfp。这个修饰符极少使用。
字段和方法
类可以看成是由一些数据(也叫状态)和操作这些状态的代码组成的。数据存储在字段中,操作数据的代码则组织在方法中。
字段和方法有两种不同的类型:关联在类自身上的类成员(也叫静态成员),关联在类的单个实例(即对象)身上的实例成员。因此成员分为四类:类字段、类方法、实例字段、实例方法
示例定义了一个简单的类 Circle,包含所有这四种成员类型:
1 | public class Circle { |
声明字段的句法
声明字段的句法和声明局部变量的句法很像,不过声明字段时还可以使用修饰符。最简单的字段声明包含字段类型和字段名。类型前面可以放零个或多个修饰符关键字或注解,名称后面可以跟着一个等号和初始化表达式,提供字段的初始值。如果两个或多个字段的类型和修饰符都相同,那么可以把一些用逗号分隔的字段名和初始化表达式放在类型后面。如下是一些有效的字段声明:
1 | int x = 1; |
字段的修饰符由零个或多个下述关键字组成。
public、protected、private:这些访问控制修饰符指明字段是否能在定义它的类之外使用,以及能在何处使用。static:如果使用,这个修饰符指明字段关联在定义它的类自身上,而不是类的实例身上。final:这个修饰符指明,字段一旦初始化,其值就不能改变。如果字段同时使用static和final修饰,那么这个字段就是编译时常量,javac 会将其内联化。final修饰的字段也可以用来创建实例不可变的类。transient:这个修饰符指明字段不是对象持久状态的一部分,无需跟对象的其他内容一起序列化。volatile:这个修饰符指明字段有额外的语义,可被两个或多个线程同时使用。volatile修饰符的意思是,字段的值必须始终从主存储器中读取和释放,不能被线程缓存(在寄存器或CPU 缓存中)。
类字段
类字段关联在定义它的类身上,而不是类的实例身上。下面这行代码声明一个类字段:
1 | public static final double PI = 3.14159; |
这行代码声明了一个字段,类型为 double,名称为 PI,并且把值设为 3.14159。static 修饰符表明这个字段是类字段。因为使用了 static 修饰符,所以类字段有时也叫静态字段。final 修饰符表明这个字段的值不会改变。因为字段 PI 表示一个常量,而且声明时加上了 final,所以无法修改它的值。在 Java(以及很多其他语言)中,习惯使用大写字母命名常量,因此这个字段的名称是 PI,而不是 pi。类字段经常用来定义常量,也就是说,static 和 final 修饰符经常放在一起使用。然而,并不是所有类字段都是常量,因此字段可以声明为static 但不声明为 final。
公开的静态字段其实就是全局变量。不过,类字段的名称会被定义它的类名限定,因此,如果不同的模块定义了同名的全局变量,Java 不会出现其他语言遇到的名称冲突问题。关于静态字段,有个重点要理解,即字段的值只有一个副本。字段关联在类自身上,而不是类的实例身上。看一下 Circle 类中的各个方法,它们都使用了同一个字段。在Circle 类内部,可以直接使用 PI 引用这个字段。但是在类的外部,既要使用类名也要使用字段名,这样才能引用这个独一无二的字段。Circle 类外部的方法要使用 Circle.PI才能访问这个字段。
类方法
和类字段一样,类方法也使用 static 修饰符声明:
1 | public static double radiansToDegrees(double rads) { |
上述代码声明了一个类方法,名为 radiansToDegrees()。这个方法只有一个参数,类型为double,而且会返回一个 double 类型的值。
和类字段一样,类方法也关联在类身上,而不是对象身上。在类的外部调用类方法时,既要指定类名也要指定方法名。例如:
1 | // 2.0弧度等于多少角度? |
如果想在定义类方法的类中调用类方法,则不用指定类名。还可以使用静态成员导入声明,减少输入的代码量。
注意,Circle.radiansToDegrees() 方法的主体使用了类字段 PI。类方法可以使用所在类(或其他类)中的任何类字段和类方法。
类方法不能使用任何实例字段或实例方法,因为类方法不关联在类的实例身上。也就是说,虽然 adiansToDegrees() 方法在 Circle 类中定义,但它不能使用 Circle 对象的任何实例成员。
实例字段
声明时没使用 static 修饰符的字段是实例字段:
1 | public double r; // 圆的半径 |
实例字段关联在类的实例身上,所以创建的每个 Circle 对象都有自己的一个 double 类型 r 字段副本。在这个例子中,r 表示某个圆的半径。每个 Circle 对象的半径和其他所有Circle 对象的都不同。
在类定义内部,实例字段只通过名称引用。在实例方法 circumference() 的主体中有一个例子。在类外部,实例字段的名称前面必须加上包含这个字段的对象的引用。例如,如果变量 c 保存的是一个 Circle 对象的引用,那么可以使用表达式 c.r 引用这个圆的半径:
1 | Circle c = new Circle(); // 创建一个Circle对象,把引用存储在c中 |
实例字段是面向对象编程的关键。实例字段保存对象的状态,实例字段的值把两个对象区分开来。
实例方法
实例方法处理类的具体实例(对象),只要声明方法时没使用 static 关键字,这个方法默认就是实例方法。
实例方法这个特性让面向对象编程开始变得有趣。示例中定义的 Circle 类包含两个实例方法,area() 和 circumference(),分别计算指定 Circle 对象表示的圆的面积和周长。若想在定义实例方法的类之外使用实例方法,必须在方法名前加上要处理的实例引用。例如:
1 | // 创建一个Circle对象,存储在变量c中 |
在实例方法内部,可以自然地访问属于调用这个方法的对象的实例字段。前面说过,经常可以把对象理解为包含状态(通过对象的字段表示)和行为(处理状态的方法)的包(bundle)。
实现所有实例方法时都使用了一个隐式参数,方法签名里没显示这个参数。这个隐式参数是 this,它的值是调用这个方法的对象引用。在我们的例子中,是一个 Circle 对象。
this引用的工作方式
方法签名中不显示隐式参数 this,是因为往往用不到。只要 Java 方法在类中访问实例字段,都默认访问 this 参数指向的对象中的字段。实例方法调用同一个类中的其他实例方法时也一样,可以理解为“在当前对象上调用实例方法”。
不过,如果想明确表明方法访问的是自己的字段或方法,可以显式使用 this 关键字。例如,可以改写 area() 方法,显式使用 this 引用实例字段:
1 | public double area() { return Circle.PI * this.r * this.r; } |
上述代码还显式使用类名引用类字段 PI。在这样简单的方法中,一般无需如此明确。然而,遇到复杂情况时,在不强制要求使用 this 的地方使用 this,有时可以让代码的意图更明确。
不过,有些情况下必须使用 this 关键字。例如,如果方法的参数或方法中的局部变量和类中的某个字段同名,那么就必须使用 this 引用这个字段,因为只使用字段名的话,引用的是方法的参数或局部变量。
例如,可以把下述方法添加到 Circle 类中:
1 | public void setRadius(double r) { |
有些开发者会谨慎选择方法的参数名,避免和字段名冲突,因此可以最大限度地少使用this。
最后,注意,实例方法可以使用 this 关键字,但类方法不能使用。这是因为类方法不关联在单个对象身上。
创建和初始化对象
构造方法是类成员,作用是初始化新建实例中的字段。
再看一下创建 Circle 对象的方式:
1 | Circle c = new Circle(); |
这行代码的意思是,调用看起来有点儿像方法的东西创建一个新 Circle 实例。其实,Circle() 是一种构造方法,是类中的成员,和类同名,而且像方法一样,有主体。
构造方法的工作方式是这样的:
-
new运算符表明我们想创建类的一个新实例。 -
首先,分配内存存储新建的对象实例;
-
然后,调用构造方法的主体,并传入指定的参数;
-
最后,构造方法使用这些参数执行初始化新对象所需的一切操作。
Java 中的每个类都至少有一个构造方法,其作用是执行初始化新对象所需的操作。定义的 Circle 类没有显式定义构造方法,因此 javac 编译器自动为我们提供了一个构造方法(叫作默认构造方法)。这个构造方法没有参数,而且不执行任何特殊的初始化操作。
定义构造方法
可是 Circle 对象显然要做些初始化操作,下面就来定义一个构造方法。
1 | public class Circle { |
重新定义了 Circle 类,包含一个构造方法,指定新建 Circle 对象的半径。借此机会,我们还把字段改成了受保护的(禁止对象随意访问)。
如果依赖编译器提供的默认构造方法,就要编写如下的代码显式初始化半径:
1 | Circle c = new Circle(); |
添加上述构造方法后,初始化变成创建对象过程的一部分:
1 | Circle c = new Circle(0.25); |
下面是一些关于命名、声明和编写构造方法的基本注意事项。
- 构造方法的名称始终和类名一样。
- 声明构造方法时不指定返回值类型,连
void都不用。 - 构造方法的主体初始化对象。可以把主体的作用想象为设定
this引用的内容。 - 构造方法不能返回
this或任何其他值。
定义多个构造方法
有时,根据遇到的情况,可能想在多个不同的方式中选择一个最便利的方式初始化对象。例如,我们可能想使用指定的值初始化圆的半径,或者使用一个合理的默认值初始化。为Circle 类定义两个构造方法的方式如下:
1 | public Circle() { r = 1.0; } |
Circle 类只有一个实例字段,由此并没有太多的初始化方式。不过在复杂的类中,经常会定义不同的构造方法。
只要构造方法的参数列表不同,为一个类定义多个构造方法完全是合法的。编译器会根据提供的参数数量和类型判断你想使用的是哪个构造方法。定义多个构造方法和方法重载的原理类似。
在一个构造方法中调用另一个构造方法
如果类有多个构造方法,会用到 this 关键字的一种特殊用法。在一个构造方法中可以使用this 关键字调用同一个类中的另一个构造方法。因此,前面 Circle 类的两个构造方法可以改写成:
1 | // 这是基本构造方法:初始化半径 |
- 如果一些构造方法共用大量的初始化代码,这种技术是有用的,因为能避免代码重复。
- 如果构造方法执行很多初始化操作,在这种复杂的情况下,这种技术十分有用。
使用 this() 时有个重大的限制:只能出现在构造方法的第一个语句中。但是,调用这个方法后,可以执行构造方法所需的任何其他初始化操作。这个限制的原因涉及自动调用超类的构造方法。
字段的默认值和初始化程序
类中的字段不一定要初始化。如果没有指定初始值,字段自动使用默认值初始化:false、\u0000、0、0.0 或 null。具体使用哪个值,根据字段的类型而定。这些默认值由 Java 语言规范规定,实例字段和类字段都适用。
如果字段的默认值不适合字段,可以显式提供其他的初始值。例如:
1 | public static final double PI = 3.14159; |
字段声明不是任何方法的一部分。Java 编译器会自动为字段生成初始化代码,然后把这些代码放在类的所有构造方法中。这些初始化代码按照字段在源码中出现的顺序插入构造方法,因此,字段的初始化程序可以使用在其之
前声明的任何字段的初始值。
例如下述代码片段是一个假设类,定义了一个构造方法和两个实例字段:
1 | public class SampleClass { |
对这个例子来说,javac 生成的构造方法其实和下述代码等效:
1 | public SampleClass() { |
如果某个构造方法的开头使用 this() 调用其他构造方法,那么字段的初始化代码不会出现在这个构造方法中。此时,初始化由 this() 调用的构造方法处理。
既然实例字段在构造方法中初始化,那么类字段在哪初始化呢?就算从不创建类的实例,类字段也关联在类身上。这意味着,类字段要在调用构造方法之前初始化。
为此,javac 会为每个类自动生成一个类初始化方法。类字段在这个方法的主体中初始化。这个方法只在首次使用类之前调用一次(经常是在 Java 虚拟机首次加载类时)
和实例字段的初始化一样,类字段的初始化表达式按照类字段在源码中的顺序插入类初始化方法。因此,类字段的初始化表达式可以使用在其之前声明的类字段。类初始化方法是内部方法,对 Java 程序员不可见。在类文件中,它的名称是 <clinit>(例如,使用 javap检查类文件时可以看到这个方法)。
初始化程序块
至此,我们知道对象可以通过字段的初始化表达式和构造方法中的任何代码初始化。类有一个类初始化方法,这个方法和构造方法不一样,不能像构造方法那样显式定义主体。不过,Java 允许编写用于初始化类字段的代码,所用的结构叫静态初始化程序。静态初始化程序由 static 关键字及随后的花括号中的代码块组成。在类定义中,静态初始化程序可以放在字段和方法定义能出现的任何位置。例如,下述代码为两个类字段执行一些重要的初始化操作:
1 | // 我们可以使用三角函数画出圆的轮廓 |
一个类可以有任意多个静态初始化程序。各个初始化程序块的主体会和所有静态字段的初始化表达式一起合并到类初始化方法中。静态初始化程序和类方法的相同点是,不能使用this 关键字,也不能使用类中的任何实例字段或实例方法。
类还可以有实例初始化程序。实例初始化程序和静态初始化程序类似,不过初始化的是对象而不是类。一个类可以有任意多个实例初始化程序,而且实例初始化程序可以放在字段和方法定义能出现的任何位置。各个实例初始化程序的主体和所有实例字段初始化表达式一起,放在类中每个构造方法的开头。实例初始化程序的外观和静态初始化程序类似,不过不使用 static 关键字。也就是说,实例初始化程序只是放在花括号里的任意 Java 代码。
实例初始化程序可以初始化数组或其他需要复杂初始化操作的字段。实例初始化程序有时很有用,因为它们把初始化代码放在字段后面,而不是单独放在构造方法中。例如:
1 | private static final int NUMPTS = 100; |
不过,现实中很少使用实例初始化程序。
子类和继承
前面定义的 Circle 是个简单的类,只通过半径区分不同的圆。假设我们要同时使用大小和位置表示圆。例如,在笛卡儿平面中,圆心在 (0, 0)、半径为 1.0 的圆,与圆心在 (1, 2)、半径为 1.0 的圆不同。为此,需要一个新类,我们称其为 PlaneCircle。
我们想添加表示圆所在位置的功能,但不想失去 Circle 类的任何现有功能。为此,可以把PlaneCircle 类定义为 Circle 类的子类,让 PlaneCircle 类继承超类 Circle 的字段和方法。
通过定义子类或扩展超类向类中添加功能的能力,是面向对象编程范式的核心。
扩展类
如何把 PlaneCircle 类定义为 Circle 类的子类:
1 | public class PlaneCircle extends Circle { |
第一行中使用的 extends 关键字。这个关键字告诉 Java,PlaneCircle 类扩展 Circle 类(或者说是 Circle 类的子类),这意味着 PlaneCircle 类会继承 Circle 类的字段和方法。
有多种方式能表达新对象类型具有
Circle的特征,而且有位置。这或许是最简单的方式,但不一定是最合适的方式,尤其是在大型系统中。
isInside() 方法的定义展示了字段继承:这个方法使用了字段 r(由 Circle 类定义),就像这个字段是在 PlaneCircle 中定义的一样。PlaneCircle 还继承了 Circle 的方法。因此,如果变量 pc 保存的值是一个 PlaneCircle 对象引用,那么可以编写如下代码:
1 | double ratio = pc.circumference() / pc.area(); |
这么做就好像 area() 和 circumference() 两个方法是在 PlaneCircle 中定义的一样。
子类的另一个特性是,每个 PlaneCircle 对象都是完全合法的 Circle 对象。如果 pc 是一个 PlaneCircle 对象的引用,那么可以把这个引用赋值给 Circle 类型的变量,忽略它表示的位置:
1 | // 位置在原点的单位圆 |
把 PlaneCircle 对象赋值给 Circle 类型的变量时无需校正。Circle 类型的变量 c 中保存的值仍然是有效的 PlaneCircle 对象,但编译器不确定这一点,因此不校正无法反向(缩小)转换:
1 | // 缩小转换需要校正(虚拟机还要做运行时检查) |
如果声明类时使用了 final 修饰符,那么这个类无法被扩展或定义子类。java.lang.String 是 final 类的一个示例。把类声明为 final 可以避免不需要的类扩展:在 String 对象上调用方法时,就算 String 类来自某个未知的外部源,你也知道这个方法是在 String 类中定义的。
超类、对象和类层次结构
在这个示例中,PlaneCircle 是 Circle 的子类,也可以说 Circle 是 PlaneCircle 的超类。类的超类在 extends 子句中指定:
1 | public class PlaneCircle extends Circle { ... } |
你定义的每个类都有超类。如果没使用 extends 子句指定超类,那么超类是 java.lang.Object。Object 是特殊的类,原因有如下两个:
- 它是 Java 中唯一一个没有超类的类;
- 所有 Java 类都从
Object类中继承方法。
因为每个类(除了 Object 类)都有超类,所以 Java 中的类组成一个类层次结构。
子类的构造方法
再看一下示例中的 PlaneCircle() 构造方法:
1 | public PlaneCircle(double r, double x, double y) { |
虽然这个构造方法显式初始化了 PlaneCircle 类中新定义的字段 cx 和 cy,但仍使用超类的 Circle() 构造方法初始化继承的字段。为了调用超类的构造方法,这个构造方法调用了super() 方法。
super 是 Java 的保留字。它的用法之一是,在子类的构造方法中调用超类的构造方法。这种用法和在一个构造方法中使用 this() 调用同一个类中的其他构造方法类似。使用super() 调用构造方法和使用 this() 调用构造方法有同样的限制:
- 只能在构造方法中像这样使用
super(); - 必须在构造方法的第一个语句中调用超类的构造方法,甚至要放在局部变量声明之前。
传给 super() 的实参必须与超类构造方法的形参匹配。如果超类定义了多个构造方法,那么 super() 可以调用其中任何一个,具体是哪个,由传入的参数决定。
构造方法链
- 创建类的实例时,Java 保证一定会调用这个类的构造方法;
- 创建任何子类的实例时,Java还保证一定会调用超类的构造方法。
为了保证第二点,Java 必须确保每个构造方法都会调用超类的构造方法。
因此,如果构造方法的第一个语句没有使用 this() 或 super() 显式调用另一个构造方法,javac 编译器会插入 super()(即调用超类的构造方法,而且不传入参数)。如果超类没有无需参数的可见构造方法,这种隐式调用会导致编译出错。
以 PlaneCircle 类为例,创建这个类的新实例时会发生下述事情:
- 首先,调用
PlaneCircle类的构造方法; - 这个构造方法显示调用了
super(r),调用Circle类的一个构造方法; Circle()构造方法会隐式调用super(),调用Circle的超类Object的构造方法(Object只有一个构造方法);- 此时,到达层次结构的顶端了,接下来开始运行构造方法;
- 首先运行
Object构造方法的主体; - 返回后,再运行
Circle()构造方法的主体; - 最后,对
super(r)的调用返回后,接着执行PlaneCircle()构造方法中余下的语句。
这个过程表明,构造方法链在一起调用;只要创建对象,就会调用一系列构造方法,从子类到超类,一直向上,直到类层次结构的顶端 Object 类为止。因为超类的构造方法始终在子类的构造方法的第一个语句中调用,所以 Object 类的构造方法的主体始终最先运行,然后运行 Object 的子类的构造方法,就这样沿着类层次结构一直向下,直到实例化的那个类为止。
调用构造方法时,超类中的字段也会被初始化。
默认构造方法
前面对构造方法链的说明漏了一点。如果构造方法没有调用超类的构造方法,Java 会隐式调用。那么,如果类没有声明构造方法呢?此时,Java 会为类隐式添加一个构造方法。这个默认的构造方法什么也不做,只是调用超类的构造方法。
例如,如果没为 PlaneCircle 类声明构造方法,那么 Java 会隐式插入下述构造方法:
1 | public PlaneCircle() { super(); } |
如果超类 Circle 没有声明无参数的构造方法,那么在这个自动插入 PlaneCircle() 类的默认构造方法中调用 super() 会导致编译出错。一般来说,如果类没有定义无参数的构造方法,那么它的所有子类必须定义显式调用超类构造方法的构造方法,而且要传入所需的参数。
如果类没有定义任何构造方法,默认会为其提供一个无参数的构造方法。声明为 public 的类,提供的构造方法也声明为 public。提供给其他类的默认构造方法则不使用任何可见性修饰符,这些构造方法具有默认的可见性。
如果创建的 public 类不能公开实例化,就应该至少声明一个非 public 的构造方法,以此避免插入默认的 public 构造方法。从来不会实例化的类(例如 java.lang.Math 或 java.lang.System),应该定义一个 private 构造方法。这种构造方法不能在类外部调用,但可以避免自动插入默认的构造方法。
遮盖超类的字段
假如 PlaneCircle 类需要知道圆心到原点 (0, 0) 的距离,我们可以再添加一个实例字段保存这个值:
1 | public double r; |
在构造方法中添加下述代码可以算出这个字段的值:
1 | this.r = Math.sqrt(cx*cx + cy*cy); // 勾股定理 |
但是等一下,这个新添加的字段 r 和超类 Circle 中表示半径的字段 r 同名了。发生这种情况时,我们说,PlaneCircle 类的 r 字段遮盖了 Circle 类的 r 字段。(当然,这个例子是故意这么做的。新字段其实应该命名为 distanceFromOrigin。)
在你编写的代码中,为字段命名时应该避免遮盖超类的字段。如果遮盖了,几乎就表明代码写得不好。
这样定义 PlaneCircle 类之后,表达式 r 和 this.r 都引用 PlaneCircle 类中的这个字段。那么,如何引用 Circle 类中保存圆的半径的 r 字段呢?有一种特殊的句法可以实现这个需求——使用 super 关键字:
1 | r // 引用PlaneCircle的字段 |
引用被遮盖的字段还有一种方式——把 this(或类的实例)校正为适当的超类,然后再访问字段:
1 | ((Circle) this).r // 引用Circle类的字段 |
如果想引用的遮盖字段不是在类的直接超类中定义的,这种校正技术特别有用。假如有三个类 A、B 和 C,它们都定义了一个名为 x 的字段,而且 C 是 B 的子类,B 是 A 的子类。那么,在 C 类的方法中可以按照下面的方式引用这些不同的字段:
1 | x // C类的x字段 |
类似地,如果 c 是 C 类的实例,那么可以像这样引用这三个字段:
1 | c.x // C类的x字段 |
目前为止,讨论的都是实例字段。类字段也能被遮盖。引用被遮盖的类字段中的值,可以使用相同的 super 句法,但没必要这么做,因为始终可以把类名放在类字段前引用这个字段。假如 PlaneCircle 的实现方觉得 Circle.PI 字段没有提供足够的小数位,那么他可以自己定义 PI 字段:
1 | public static final double PI = 3.14159265358979323846; |
现在,PlaneCircle 类中的代码可以通过表达式 PI 或 PlaneCircle.PI 使用这个更精确的值,还可以使用表达式 super.PI 和 Circle.PI 引用精度不高的旧值。不过,PlaneCircle继承的 area() 和 circumference() 方法是在 Circle 类中定义的,所以,就算 Circle.PI 被PlaneCircle.PI 遮盖了,这两个方法还是会使用 Circle.PI 的值。
覆盖超类的方法
如果类中定义的某个实例方法和超类的某个方法有相同的名称、返回值类型和参数,那么这个方法会覆盖(override)超类中对应的方法。在这个类的对象上调用这个方法时,调用的是新定义的方法,而不是超类中定义的旧方法。
覆盖方法的返回值类型可以是原方法返回值的子类(没必要一模一样)。这叫作协变返回(covariant return)。
方法覆盖是面向对象编程中一项重要且有用的技术。PlaneCircle 没有覆盖 Circle 类定义的任何方法,不过,假设我们要再定义一个 Circle 的子类,名为 Ellipse。
此时,Ellipse 一定要覆盖 Circle 的 area() 和 circumference() 方法,因为计算圆的面积和周长的公式不适用于椭圆。
下面针对方法覆盖的讨论只涉及实例方法。类方法的运作机制完全不同,无法覆盖。和字段一样,类方法也能被子类遮盖,但不能覆盖。好的编程风格是调用类方法时始终在前面加上定义这个方法的类名。如果把类名当成方法名的一部分,那么这两个方法的名称就不一样,因此其实并没有遮盖什么。
在进一步讨论方法覆盖之前,要理解方法覆盖和方法重载之间的区别。方法重载指的是(在同一个类中)定义多个名称相同但参数列表不同的方法。这和方法覆盖十分不同,因此别混淆了。
覆盖不是遮盖
虽然 Java 使用很多类似的方式对待字段和方法,但方法覆盖和字段遮盖一点儿都不一样。为了引用遮盖的字段,只需把对象校正成适当超类的实例,但不能使用这种技术调用覆盖的实例方法。下述代码展示了这个重要区别:
1 | class A { // 定义一个类,名为A |
初看起来,可能觉得方法覆盖和字段遮盖的这种区别有点奇怪,但稍微想想,确实有道理。
假设我们要处理一些 Circle 和 Ellipse 对象。为了记录这些圆和椭圆,我们把它们存储在一个 Circle[] 类型的数组中。这么做是可以的,因为 Ellipse 是 Circle 的子类,所以所有 Ellipse 对象都是合法的 Circle 对象。
遍历这个数组的元素时,不需要知道也无需关心元素是 Circle 对象还是 Ellipse 对象。不过,需要密切关注的是,在数组的元素上调用 area() 方法是否能得到正确的值。也就是说,如果是椭圆对象就不能使用计算圆面积的公式。
我们真正希望的是,计算面积时对象能“做正确的事”:Circle 对象使用自己的方式计算,Ellipse 对象使用对椭圆来说正确的方式计算。
这样理解,就不会对 Java 使用不同的方式处理方法覆盖和字段遮盖感到奇怪了。
虚拟方法查找
如果一个 Circle[] 类型的数组保存的是 Circle 和 Ellipse 对象,那么编译器怎么知道要在具体的元素上调用 Circle 类还是 Ellipse 类的 area() 方法呢?事实上,源码编译器在编译时并不知道要调用哪个方法。
不过,javac 生成的字节码会在运行时使用“虚拟方法查找”(virtual method lookup)。解释器运行代码时,会查找适用于数组中各个对象的 area() 方法。即,解释器解释表达式o.area() 时,会检查变量 o 引用的对象的真正运行时类型,然后找到适用于这个类型的area() 方法。
JVM 不会直接使用关联在变量 o 表示的静态类型身上的 area() 方法,如果这么做,前面详述的方法覆盖机制就不成立了。Java 的实例方法默认使用虚拟查找。之后会详细介绍编译时和运行时类型,以及它们对虚拟方法查找的影响。
调用被覆盖的方法
我们已经说明了方法覆盖和字段遮盖之间的重要区别。然而,调用被覆盖的方法的 Java 句法和访问被遮盖的字段的句法十分类似——都使用 super 关键字。如下述代码所示:
1 | class A { |
前面说过,使用 super 引用被遮盖的字段时,相当于把 this 校正为超类类型,然后通过超类类型访问字段。不过,使用 super 调用被覆盖的方法和校正 this 引用不是一回事。也就是说,在上述代码中,表达式 super.f() 和 ((A)this).f() 的作用不一样。
解释器使用 super 句法调用实例方法时,会执行一种修改过的虚拟方法查找。第一步和常规的虚拟方法查找一样,确定调用方法的对象属于哪个类。正常情况下,运行时会在这个类中寻找对应的方法定义。但是,使用 super 句法调用方法时,先在这个类的超类中查找。如果超类直接实现了这个方法,那就调用这个方法。如果超类继承了这个方法,那就调用继承的方法。
注意,super 关键字调用的是方法的直接覆盖版本。假设 A 类有个子类 B,B 类有个子类C,而且这三个类都定义了同一个方法 f()。在 C.f() 方法中使用 super.f() 可以调用方法 B.f(),因为 C.f() 直接覆盖了 B.f()。但是,C.f() 不能直接调用 A.f(),因为 super.super.f() 不是合法的 Java 句法。当然,如果 C.f() 调用了 B.f(),有合理的理由认为,B.f() 可能会调用 A.f()。
使用被覆盖的方法时,这种链式调用相当常见。覆盖方法是增强方法功能,但不完全取代这个方法的一种方式。
别把调用被覆盖方法的
super和构造方法中调用超类构造方法的super()搞混了。虽然二者使用的关键字相同,但却是两种完全不同的句法。具体而言,可以在类中的任何位置使用super调用超类中被覆盖的方法,但是只能在构造方法的第一个语句中使用super()调用超类的构造方法。
还有一点很重要,即记住,只能在覆盖某个方法的类内部使用 super 调用被覆盖的方法。假如 e 引用的是一个 Ellipse 对象,那么无法在 e 上调用 Circle 类中定义的 area() 方法。
数据隐藏和封装
类由一些数据和方法组成。目前,我们尚未说明的最重要的面向对象技术之一是,把数据隐藏在类中,只能通过方法获取。这种技术叫作封装(encapsulation),因为它把数据(和内部方法)安全地密封在类这个“容器”中,只能由可信的用户(即这个类中的方法)访问。
为什么要这么做呢?最重要的原因是,隐藏类的内部实现细节。如果避免让程序员依赖这些细节,你就可以放心地修改实现,而无需担心会破坏使用这个类的现有代码。
你应该始终封装自己的代码。如果没有封装好,那么几乎无法推知并最终确认代码是否正确,尤其是在多线程环境中(而基本上所有 Java 程序都运行在多线程环境中)。
使用封装的另一个原因是保护类,避免有意或无意做了糊涂事。类中经常包含一些相互依赖的字段,而且这些字段的状态必须始终如一。如果允许程序员(包括你自己)直接操作这些字段,修改某个字段后可能不会修改重要的相关字段,那么类的状态就前后不一致了。然而,如果必须调用方法才能修改字段,那么这个方法可以做一切所需的措施,确保状态一致。类似地,如果类中定义的某些方法仅供内部使用,隐藏这些方法能避免这个类的用户调用这些方法。
封装还可以这样理解:把类的数据都隐藏后,方法就是在这个类的对象上能执行的唯一一种可能的操作。
只要小心测试和调试方法,就可以认为类能按预期的方式运行。然而,如果类的所有字段都可以直接操作,那么要测试的可能性根本数不完。
隐藏类的字段和方法还有一些次要的原因。
- 如果内部字段和方法在外部可见,会弄乱类的 API。让可见的字段尽量少,可以保持类的整洁,从而更易于使用和理解。
- 如果方法对类的使用者可见,就必须为其编写文档。把方法隐藏起来,可以节省时间和精力。
访问控制
Java 定义了一些访问控制规则,可以禁止类的成员在类外部使用。 public 关键字,连同 protected和 private(还有一个特殊的),是访问控制修饰符,为字段或方法指定访问规则。
访问包
Java 语言不直接支持包的访问控制。访问控制一般在类和类的成员这些层级完成
访问类
默认情况下,顶层类在定义它的包中可以访问。不过,如果顶层类声明为 public,那么在任何地方都能访问。
访问成员
类的成员在类的主体里始终可以访问。默认情况下,在定义这个类的包中也可以访问成员。这种默认的访问等级一般叫作包访问。这只是四个可用的访问等级中的一个。其他三个等级使用 public、protected 和 private 修饰符定义。下面是使用这三个修饰符的示例代码:
1 | public class Laundromat { // 所有人都可以使用这个类 |
下述访问规则适用于类的成员:
- 类中的所有字段和方法在类的主体里始终可以使用。
- 如果类的成员使用
public修饰符声明,那么可以在能访问这个类的任何地方访问这个成员。这是限制最松的访问控制类型。 - 如果类的成员声明为
private,那么除了在类内部之外,其他地方都不能访问这个成员。这是限制最严的访问控制类型。 - 如果类的成员声明为
protected,那么包里的所有类都能访问这个成员(等同于默认的包访问规则),而且在这个类的任何子类的主体中也能访问这个成员,而不管子类在哪个包中定义。 - 如果声明类的成员时没使用任何修饰符,那么使用默认的访问规则(有时叫包访问),包中的所有类都能访问这个成员,但在包外部不能访问。
默认的访问规则比
protected严格,因为默认规则不允许在包外部的子类中访问成员。
使用 protected 修饰的成员时要格外小心。假设 A 类使用 protected 声明了一个字段 x,而且在另一个包中定义的 B 类继承 A 类(重点是 B 类在另一包中定义)。因此,B 类继承了这个 protected 声明的字段 x,那么,在 B 类的代码中可以访问当前实例的这个字段,而且引用 B 类实例的代码也能访问这个字段。但是,这并不意味着在 B 类的代码中能读取任何一个 A 类实例的受保护字段。
下面通过代码讲解这个语言细节。A 类的定义如下:
1 | package javanut6.ch03; |
B 类的定义如下:
1 | package javanut6.ch03.different; |
Java 的包不能“嵌套”,所以
javanut6.ch03.different和javanut6.ch03是不同的包。javanut6.ch03.different不以任何方式包含在javanut6.ch03中,也和javanut6.ch03没有任何关系。
可是,如果我们试图把下面这个新方法添加到 B 类中,会导致编译出错,因为 B 类的实例无法访问任何一个 A 类的实例:
1 | public String examine(A a) { |
如果把这个方法改成:
1 | public String examine(B b) { |
就能编译通过,因为同一类型的多个实例可以访问各自的 protected 字段。当然,如果 B 类和 A 类在同一包中,那么任何一个 B 类的实例都能访问任何一个 A 类实例的全部受保护字段,因为使用 protected 声明的字段对同一个包中的每个类都可见。
访问控制和继承
Java 规范规定:
- 子类继承超类中所有可以访问的实例字段和实例方法;
- 如果子类和超类在同一个包中定义,那么子类继承所有没使用
private声明的实例字段和方法; - 如果子类在其他包中定义,那么它继承所有使用
protected和public声明的实例字段和方法; - 使用
private声明的字段和方法绝不会被继承;类字段和类方法也一样; - 构造方法不会被继承(而是链在一起调用)。
不过,有些程序员会对“子类不继承超类中不可访问的字段和方法”感到困惑。这似乎暗示了,创建子类的实例时不会为超类中使用 private 声明的字段分配内存。然而,这不是上述规定想表述的。
其实,子类的每个实例都包含一个完整的超类实例,其中包括所有不可访问的字段和方法。
某些成员可能无法访问,这似乎和类的成员在类的主体中始终可以访问相矛盾。为了避免误解,我们要使用“继承的成员”表示那些可以访问的超类成员。
那么,关于成员访问性的正确表述应该是:“所有继承的成员和所有在类中定义的成员都是可以访问的。”这句话还可以换种方式说:
- 类继承超类的所有实例字段和实例方法(但不继承构造方法);
- 在类的主体中始终可以访问这个类定义的所有字段和方法,而且还可以访问继承自超类的可访问的字段和方法。
成员访问规则总结
| 能否访问 | 公开 | 受保护 | 默认 | 私有 |
|---|---|---|---|---|
| 定义成员的类 | 是 | 是 | 是 | 是 |
| 同一个包中的类 | 是 | 是 | 是 | 否 |
| 不同包中的子类 | 是 | 是 | 否 | 否 |
| 不同的包,也不是子类 | 是 | 否 | 否 | 否 |
下面是一些使用可见性修饰符的经验法则。
- 只使用
public声明组成类的公开 API 的方法和常量。使用public声明的字段只能是常量和不能修改的对象,而且必须同时使用final声明。 - 使用
protected声明大多数使用这个类的程序员不会用到的字段和方法,但在其他包中定义子类时可能会用到。 - 如果字段和方法供类的内部实现细节使用,但是同一个包中协作的类也要使用,那么就使用默认的包可见性。
- 使用
private声明只在类内部使用,在其他地方都要隐藏的字段和方法。
严格来说,使用
protected声明的成员是类公开 API 的一部分,必须为其编写文档,而且不能轻易修改,以防破坏依赖这些成员的代码。
如果不确定该使用 protected还是 private ,那么先使用 private。如果太过严格,可以稍微放松访问限制(如果是字段的话,还可以提供访问器方法)。设计 API 时这么做尤其重要,因为提高访问限制是不向后兼容的改动,可能会破坏依赖成员访问性的代码。
数据访问器方法
在 Circle 类那个示例中,我们使用 public 声明表示圆半径的字段。Circle 类可能有很好的理由让这个字段可以公开访问;这个类很简单,字段之间不相互依赖。但是,当前实现的 Circle 类允许对象的半径为负数,而半径为负数的圆肯定不存在。可是,只要半径存储在声明为 public 的字段中,任何程序员都能把这个字段的值设为任何想要的值,而不管这个值有多么不合理。唯一的办法是限制程序员,不让他们直接访问这个字段,然后定义public 方法,间接访问这个字段。提供 public 方法读写字段和把字段本身声明为 public不是一回事。目前而言,二者的区别是,方法可以检查错误。
例如,我们或许不想让 Circle 对象的半径使用负数——负数显然不合理,但目前的实现没有阻止这么做。以下展示了使用数据隐藏和封装技术定义的 Circle 类,避免把半径设为负数。
1 | package shapes; // 为这个类指定一个包 |
Circle 类的这个版本使用 protected 声明 r 字段,还定义了访问器方法 getRadius() 和setRadius(),用于读写这个字段的值,而且限制半径不能为负数。r 字段使用 protected声明,所以可以在子类中直接(且高效地)访问。
我们在一个名为 shapes 的包中定义 Circle 类。因为 r 字段使用 protected 声明,所以shapes 包中的任何其他类都能直接访问这个字段,而且能把它设为任何值。这里假设shapes 包中的所有类都由同一个作者或者协作的多个作者编写,而且包中的类相互信任,不会滥用拥有的访问权限影响彼此的实现细节。
最后,限制半径不能使用负数的代码在一个使用 protected 声明的方法中,这个方法是checkRadius()。虽然 Circle 类的用户无法调用这个方法,但这个类的子类可以调用,而且如果想修改对半径的限制,还可以覆盖这个方法。
在 Java 中,数据访问器方法的命名有个通用约定,即以“get”和“set”开头。但是,如果要访问的字段是 boolean 类型,那么读取字段的方法使用的名称可能会以“is”开头。例如,名为 readable 的 boolean 类型字段对应的访问器方法是
isReadable()而不是getReadable()。
抽象类和方法
我们把 Circle 类声明为 shapes 包的一部分。假设我们计划实现多个表示形状的类:Rectangle、Square、Ellipse、Triangle 等。我们可以在这些表示形状的类中定义两个基本方法:area() 和 circumference()。那么,为了能方便处理由形状组成的数组,这些表示形状的类最好有个共同的超类 Shape。这样组织类层次结构的话,每个形状对象,不管具体表示的是什么形状,都能赋予类型为 Shape 的变量、字段或数组元素。我们想在 Shape 类中封装所有形状共用的功能(例如,area() 和 circumference() 方法)。但是,通用的 Shape 类不表示任何类型的形状,所以不能为这些方法定义有用的实现。Java使用抽象方法解决这种问题。
Java 允许使用 abstract 修饰符声明方法,此时只定义方法但不实现方法。abstract 修饰的方法没有主体,只有一个签名和一个分号。以下是 abstract 方法和这些方法所在的abstract 类相关的规则。
-
只要类中有一个
abstract方法,那么这个类本身就自动成为abstract类,而且必须声明为abstract类,否则会导致编译出错。 -
abstract类无法实例化。 -
abstract类的子类必须覆盖超类的每个abstract方法并且把这些方法全部实现(即提供方法主体),才能实例化。这种类一般叫作具体子类(concrete subclass),目的是强调它不是抽象类。 -
如果
abstract类的子类没有实现继承的所有abstract方法,那么这个子类还是抽象类,而且必须使用abstract声明。 -
使用
static、private和final声明的方法不能是抽象方法,因为这三种方法在子类中不能覆盖。类似地,final类中不能有任何abstract方法。 -
就算类中没有
abstract方法,这个类也能声明为abstract。使用这种方式声明的abstract类表明实现的不完整,要交给子类实现。这种类不能实例化。
下 面 通 过 一 个 示 例 说 明 这 些 规 则 的 运 作 方 式。 如 果 定 义 Shape 类 时 把 area() 和circumference() 声明为 abstract 方法,那么 Shape 的子类必须实现这两个方法才能实例化。也就是说,每个 Shape 对象都要确保实现了这两个方法。以下示例展示了如何编写代码。在这段代码中,定义了一个抽象的 Shape 类和两个具体子类。
1 | public abstract class Shape { |
Shape 类中每个抽象方法的括号后面都是分号,没有花括号,也没定义方法的主体。使用以上示例中定义的这几个类可以编写如下的代码:
1 | Shape[] shapes = new Shape[3]; // 创建一个保存形状的数组 |
有两点要注意。
- Shape 类的子类对象可以赋值给 Shape 类型数组中的元素,无需校正。这又是一个放大转换引用类型的例子。
- 即便 Shape 类没有定义
area()和circumference()方法的主体,各个 Shape 对象还是能调用这两个方法。调用这两个方法时,使用虚拟方法查找技术找到要调用的方法。因此,圆的面积使用Circle类中定义的方法计算,矩形的面积使用Rectangle类中定义的方法计算。
转换引用类型
对象可以在不同的引用类型之间转换。和基本类型一样,引用类型转换可以是放大转换(编译器自动完成),也可以是需要校正的缩小转换(或许运行时还要检查)。要想理解引用类型的转换,必须理解引用类型组成的层次结构,这个体系叫作类层次结构。
每个 Java 引用类型都扩展其他类型,被扩展的类型是这个类型的超类。类型继承超类的字段和方法,然后定义属于自己的一些额外的字段和方法。在 Java 中,类层次结构的根是一个特殊的类,名为 Object。所有 Java 类都直接或间接地扩展 Object 类。Object 类定义了一些特殊的方法,所有对象都能继承(或覆盖)这些方法。
简单理解类层次结构之后,我们可以定义引用类型的转换规则了。
-
对象不能转换成不相关的类型。例如,就算使用校正运算符,Java 编译器也不允许把
String对象转换成Point对象。 -
对象可以转换成超类类型,或者任何祖先类类型。这是放大转换,因此不用校正。例如,
String对象可以赋值给Object类型的变量,或者传入期待Object类型参数的方法。 -
对象可以转换成子类类型,但这是缩小转换,需要校正。Java 编译器临时允许执行这种转换,但 Java 解释器在运行时会做检查,确保转换有效。根据程序的逻辑,确认对象的确是子类的实例后才会把对象校正成子类类型。否则,解释器会抛出
ClassCastException异常。例如,如果把一个String对象赋值给Object类型的变量,那么后面可以校正这个变量的值,再变回String类型。1
2Object o = "string"; // 把String对象放大转换成Object类型
String s = (String) o; // 程序后面再把这个Object对象缩小转换成String类型
没有执行转换操作,而是直接把对象当成超类的实例。这种行为有时称为里氏替换原则(Liskov substitution principle)
数组是对象,而且有自己的一套转换规则。首先,任何数组都能放大转换成 Object 对象。带校正的缩小转换能把这个对象转换回数组。下面是一个示例:
1 | // 把数组放大转换成Object对象 |
除了能把数组转换成对象之外,如果两个数组的“基类型”是可以相互转换的引用类型,那么数组还能转换成另一个类型的数组。例如:
1 | // 这是一个字符串数组 |
注意,这些数组转换规则只适用于由对象或数组组成的数组。基本类型的数组不能转换为任何其他数组类型,就算基本基类型之间能相互转换也不行:
1 | // 就算int类型能放大转换成double类型 |
修饰符总结
访问控制修饰符
Java中,可以使用访问控制符来保护对类、变量、方法和构造方法的访问。Java 支持 4 种不同的访问权限。
-
default(即默认,什么也不写):在同一包内可见,不使用任何修饰符。可以使用的对象包括类、接口、变量和方法。 -
private:在同一类内可见。可以使用的对象包括变量和方法。 注意:不能修饰类(外部类) -
public:对所有类可见。可以使用的对象包括类、接口、变量和方法 -
protected: 对同一包内的类和所有子类可见。可以使用的对象包括变量和方法。 注意:不能修饰类(外部类)。
非访问修饰符
为了实现一些其他的功能,Java 也提供了许多非访问修饰符。
-
static修饰符:用来修饰类方法和类变量。静态变量:
static关键字用来声明独立于对象的静态变量,无论一个类实例化多少对象,它的静态变量只有一份拷贝。 静态变量也被称为类变量。局部变量不能被声明为static变量。静态方法:
static关键字用来声明独立于对象的静态方法。静态方法不能使用类的非静态变量。静态方法从参数列表得到数据,然后计算这些数据。对类变量和方法的访问可以直接使用 classname.variablename 和 classname.methodname 的方式访问。
-
final修饰符:用来修饰类、方法和变量,final修饰的类不能够被继承,修饰的方法不能被继承类重新定义,修饰的变量为常量,是不可修改的。final变量:final表示“最后的、最终的”含义,变量一旦赋值后,不能被重新赋值。被final修饰的实例变量必须显式指定初始值。final修饰符通常和static修饰符一起使用来创建类常量 -
abstract修饰符:用来创建抽象类和抽象方法。抽象类:抽象类不能用来实例化对象,声明抽象类的唯一目的是为了将来对该类进行扩充。抽象类可以包含抽象方法和非抽象方法。方法可以通过使用保留词void作为返回类型来表明不会返回任何值。一个类不能同时被
abstract和final修饰。如果一个类包含抽象方法,那么该类一定要声明为抽象类,否则将出现编译错误。 -
synchronized和volatile修饰符,主要用于线程的编程。
总结
| 修饰符 | 用于 | 意义 |
|---|---|---|
abstract |
类 | 这个类不能实例化,而且可能包含未实现的方法 |
| 接口 | 所有接口都是抽象的。声明接口时这个修饰符是可选的 | |
| 方法 | 这个方法没有主体,主体由子类提供。签名后面是一个分号。所在的类必须也是抽象的 | |
default |
方法 | 这个接口方法的实现是可选的。接口为不想实现这个方法的类提供了一个默认实现。 |
final |
类 | 不能创建这个类的子类 |
| 方法 | 不能覆盖这个方法 | |
| 字段 | 这个字段的值不能改变。static final 修饰的字段是编译时常量 |
|
| 变量 | 值不能改变的局部变量、方法参数或异常参数 | |
native |
方法 | 这个方法使用某种与平台无关的方式实现(经常使用 C 语言)。没有提供主体,签名后面是一个分号 |
| 无(包) | 类 | 没声明为 public 的类只能在包中访问 |
| 接口 | 没声明为 public 的接口只能在包中访问 | |
| 成员 | 没声明为 private、protected 或 public 的成员具有包可见性,只能在包中访问 | |
private |
成员 | 这个成员只在定义它的类中可以访问 |
protected |
成员 | 这个成员只在定义它的包中和子类中可以访问 |
public |
类 | 能访问所在包的地方都能访问这个类 |
| 接口 | 能访问所在包的地方都能访问这个接口 | |
| 成员 | 能访问所在类的地方都能访问这个成员 | |
strictfp |
类 | 这个类中的所有方法都隐式声明为 strictfp |
| 方法 | 这个方法必须使用严格遵守 IEEE 754 标准的方式执行浮点运算。具体而言,所有数值,包括中间结果,都要使用 IEEEfloat 或 double 类型表示,而且不能利用本地平台浮点格式或硬件提供的额外精度或取值范围。这个修饰符极少使用 | |
static |
类 | 使用 static 声明的内部类是顶层类,而不是所在类的成员。 |
| 方法 | static 方法是类方法。不隐式传入 this 对象引用。可通过类名调用 |
|
| 字段 | static 字段是类字段。不管创建多少类实例,这个字段都只有一个实例。可通过类名访问 |
|
| 初始化程序 | 这个初始化程序在加载类时运行,而不是创建实例时运行 | |
synchronized |
方法 | 这个方法对类或实例执行非原子操作,所以必须小心,确保不能让两个线程同时修改类或实例。对 static 方法来说,执行方法之前先为类获取一个锁。对非 static 方法来说,会为具体的对象实例获取一个锁。 |
transient |
字段 | 这个字段不是对象持久化状态的一部分,因此不会随对象一起序列化。在对象序列化时使用,参见 java.io.ObjectOutputStream |
volatile |
字段 | 这个字段能被异步线程访问,因此必须对其做些特定的优化。这个修饰符有时可以替代 synchronized。 |
面向对象编程(OOP)
- 对管理人员,它实现了更快和更廉价的开发与维护过程。
- 对分析 与设计人员,建模处理变得更加简单,能生成清晰、易于维护的设计方案。
- 对程序员,对象模型显得如此高雅和浅显。
基本特征:
- 所有东西都是对象。可将对象想象成一种新型变量;它保存着数据,但可要求它对自身进行操作。理论上讲,可从要解决的问题身上提出所有概念性的组件,然后在程序中将其表达为一个对象。
- 程序是一大堆对象的组合;通过消息传递,各对象知道自己该做些什么。为了向对象发出请求,需向那个对象“发送一条消息”。更具体地讲,可将消息想象为一个调用请求,它调用的是从属于目标对象的一个子例程或函数。
- 每个对象都有自己的存储空间,可容纳其他对象。或者说,通过封装现有对象,可制作出新型对象。所以,尽管对象的概念非常简单,但在程序中却可达到任意高的复杂程度。
- 每个对象都有一种类型。根据语法,每个对象都是某个“类”的一个“实例”。其中,“类”(Class)是“类型”(Type)的同义词。一个类最重要的特征就是“能将什么消息发给它?”。
- 同一类所有对象都能接收相同的消息。由于类型为“圆”(Circle)的一个对象也属于类型为“形状”(Shape)的一个对象,所以一个圆完全能接收形状消息。这意味着可让程序代码统一指挥“形状”,令其自动控制所有符合“形状”描述的对象,其中自然包括“圆”。这一特性称为对象的“可替换性”,是OOP最重要的概念之一。
面向对象和面向过程的区别
-
面向过程 :面向过程性能比面向对象高。 因为类调用时需要实例化,开销比较大,比较消耗资源,所以当性能是最重要的考量因素的时候,比如单片机、嵌入式开发、Linux/Unix等一般采用面向过程开发。但是,面向过程没有面向对象易维护、易复用、易扩展。
-
面向对象 :面向对象易维护、易复用、易扩展。 因为面向对象有封装、继承、多态性的特性,所以可以设计出低耦合的系统,使系统更加灵活、更加易于维护。但是,面向对象性能比面向过程低。
面向过程 :面向过程性能比面向对象高。 因为类调用时需要实例化,开销比较大,比较消耗资源,所以当性能是最重要的考量因素的时候,比如单片机、嵌入式开发、Linux/Unix等一般采用面向过程开发。
这个并不是根本原因,面向过程也需要分配内存,计算内存偏移量,Java性能差的主要原因并不是因为它是面向对象语言,而是Java是半编译语言,最终的执行代码并不是可以直接被CPU执行的二进制机械码。
而面向过程语言大多都是直接编译成机械码在电脑上执行,并且其它一些面向过程的脚本语言性能也并不一定比Java好。
Java是一种面向对象的编程语言。面向对象编程,英文是Object-Oriented Programming,简称OOP。
那什么是面向对象编程?
和面向对象编程不同的,是面向过程编程。面向过程编程,是把模型分解成一步一步的过程。比如,老板告诉你,要编写一个TODO任务,必须按照以下步骤一步一步来:
- 读取文件;
- 编写TODO;
- 保存文件。
而面向对象编程,顾名思义,你得首先有个对象:
有了对象后,就可以和对象进行互动:
1 | GirlFriend gf = new GirlFriend(); |
因此,面向对象编程,是一种通过对象的方式,把现实世界映射到计算机模型的一种编程方法。
内存的分配
有六个地方可以保存数据:
- 寄存器。这是最快的保存区域,因为它位于和其他所有保存方式不同的地方:处理器内部。然而,寄存器的数量十分有限,所以寄存器是根据需要由编译器分配。我们对此没有直接的控制权,也不可能在自己的程序里找到寄存器存在的任何踪迹
- 堆栈。驻留于常规 RAM(随机访问存储器)区域,但可通过它的“堆栈指针”获得处理的直接支持。堆栈指针若向下移,会创建新的内存;若向上移,则会释放那些内存。这是一种特别快、特别有效的数据保存方式,仅次于寄存器。创建程序时,Java编译器必须准确地知道堆栈内保存的所有数据的“长度”以及“存在时间”。这是由于它必须生成相应的代码,以便向上和向下移动指针。这一限制无疑影响了程序的灵活性,所以尽管有些 Java数据要保存在堆栈里——特别是对象句柄,但Java对象并不放到其中
- 堆。一种常规用途的内存池(也在 RAM区域),其中保存了Java对象。和堆栈不同,“内存堆”或“堆”(Heap)最吸引人的地方在于编译器不必知道要从堆里分配多少存储空间,也不必知道存储的数据要
在堆里停留多长的时间。因此,用堆保存数据时会得到更大的灵活性。要求创建一个对象时,只需用new命令编制相关的代码即可。执行这些代码时,会在堆里自动进行数据的保存。当然,为达到这种灵活性,必然会付出一定的代价:在堆里分配存储空间时会花掉更长的时间! - 静态存储。这儿的“静态”(Static)是指“位于固定位置”(尽管也在 RAM里)。程序运行期间,静态存储的数据将随时等候调用。可用static关键字指出一个对象的特定元素是静态的。但 Java对象本身永远都不会置入静态存储空间。
- 常数存储。常数值通常直接置于程序代码内部。这样做是安全的,因为它们永远都不会改变。有的常数需要严格地保护,所以可考虑将它们置入只读存储器(ROM)。
- 非 RAM存储。若数据完全独立于一个程序之外,则程序不运行时仍可存在,并在程序的控制范围之外。其中两个最主要的例子便是“流式对象”和“固定对象”。对于流式对象,对象会变成字节流,通常会发给另一台机器。而对于固定对象,对象保存在磁盘中。即使程序中止运行,它们仍可保持自己的状态不变。对于这些类型的数据存储,一个特别有用的技巧就是它们能存在于其他媒体中。一旦需要,甚至能将它们恢复成普通的、基于RAM的对象。
面向对象基础
面向对象编程,是一种通过对象的方式,把现实世界映射到计算机模型的一种编程方法。
现实世界中,我们定义了“人”这种抽象概念,而具体的人则是“小明”、“小红”、“小军”等一个个具体的人。所以,“人”可以定义为一个类(class),而具体的人则是实例(instance):
| 现实世界 | 计算机模型 | Java代码 |
|---|---|---|
| 人 | 类 / class | class Person { } |
| 小明 | 实例 / ming | Person ming = new Person() |
| 小红 | 实例 / hong | Person hong = new Person() |
| 小军 | 实例 / jun | Person jun = new Person() |
同样的,“书”也是一种抽象的概念,所以它是类,而《Java核心技术》、《Java编程思想》、《Java学习笔记》则是实例:
| 现实世界 | 计算机模型 | Java代码 |
|---|---|---|
| 书 | 类 / class | class Book { } |
| Java核心技术 | 实例 / book1 | Book book1 = new Book() |
| Java编程思想 | 实例 / book2 | Book book2 = new Book() |
| Java学习笔记 | 实例 / book3 | Book book3 = new Book() |
class和instance
所以,只要理解了class和instance的概念,基本上就明白了什么是面向对象编程。
class是一种对象模版,它定义了如何创建实例,因此,class本身就是一种数据类型
而instance是对象实例,instance是根据class创建的实例,可以创建多个instance,每个instance类型相同,但各自属性可能不相同
定义class
在Java中,创建一个类,例如,给这个类命名为Person,就是定义一个class:
1 | class Person { |
一个class可以包含多个字段(field),字段用来描述一个类的特征。上面的Person类,我们定义了两个字段,一个是String类型的字段,命名为name,一个是int类型的字段,命名为age。因此,通过class,把一组数据汇集到一个对象上,实现了数据封装。
public是用来修饰字段的,它表示这个字段可以被外部访问。
我们再看另一个Book类的定义:
1 | class Book { |
创建实例
定义了class,只是定义了对象模版,而要根据对象模版创建出真正的对象实例,必须用new操作符。
new操作符可以创建一个实例,然后,我们需要定义一个引用类型的变量来指向这个实例:
1 | Person ming = new Person(); |
上述代码创建了一个Person类型的实例,并通过变量ming指向它。
注意区分Person ming是定义Person类型的变量ming,而new Person()是创建Person实例。
有了指向这个实例的变量,我们就可以通过这个变量来操作实例。访问实例变量可以用变量.字段,例如:
1 | ming.name = "Xiao Ming"; // 对字段name赋值 |
上述两个变量分别指向两个不同的实例,它们在内存中的结构如下:
1 | ┌──────────────────┐ |
两个instance拥有class定义的name和age字段,且各自都有一份独立的数据,互不干扰。
方法的性质
方法的参数按值传递
在方法中参数变量的使用方法和局部变量相同,唯一不同的是参数变量的初始值是由调用方提供的。方法处理的是参数的值,而非参数本身。这种方式产生的结果是在静态方法中改变一个参数变量的值对调用者没有影响。本书中我们一般不会修改参数变量。值传递也意味着数组参数将会是原数组的别名(见 1.1.5.4 节)——方法中使用的参数变量能够引用调用者的数组并改变其内容(只是不能改变原数组变量本身)。例如,Arrays.sort() 将能够改变通过参数传递的数组的内容,将其排序。
方法名可以被重载
例如,Java 的 Math 包使用这种方法为所有的原始数值类型实现了Math.abs()、Math.min() 和 Math.max() 函数。重载的另一种常见用法是为函数定义两个版本,其中一个需要一个参数而另一个则为该参数提供一个默认值。
方法只能返回一个值,但可以包含多个返回语句
一个 Java 方法只能返回一个值,它的类型是方法签名中声明的类型。静态方法第一次执行到一条返回语句时控制权将会回到调用代码中。尽管可能存在多条返回语句,任何静态方法每次都只会返回一个值,即被执行的第一条返回语句的参数。
方法可以产生副作用
方法的返回值可以是 void,这表示该方法没有返回值。返回值为void 的静态函数不需要明确的返回语句,方法的最后一条语句执行完毕后控制权将会返回给调用方。我们称 void 类型的静态方法会产生副作用(接受输入、产生输出、修改数组或者改变系统状态)。例如,我们的程序中的静态方法 main() 的返回值就是 void,因为它的作用是向外输出。技术上来说,数学方法的返回值都不会是 void(Math.random() 虽然不接受参数但也有返回值)
静态方法, static关键字
通常,我们创建类时会指出那个类的对象的外观与行为。除非用new创建那个类的一个对象,否则实际上并未得到任何东西。只有执行了 new后,才会正式生成数据存储空间,并可使用相应的方法。但在两种特殊的情形下,上述方法并不堪用。
- 一种情形是只想用一个存储区域来保存一个特定的数据——无论要创建多少个对象,甚至根本不创建对象。
- 另一种情形是我们需要一个特殊的方法,它没有与这个类的任何对象关联。也就是说,即使没有创建对象,也需要一个能调用的方法。
为满足这两方面的要求,可使用static(静态)关键字。
这些方法由出现在public或private关键词之后的static关键词标识。方法封装了由一系列语句所描述的运算。
方法需要参数(某种数据类型的值)并根据参数计算出某种数据类型的返回值(例如数学函数的结果)或者产生某种副作用(例如打印一个值)。

典型静态方法的实现
计算一个整数的绝对值
1 | public static int abs(int x){ |
计算一个浮点数的绝对值
1 | public static double abs(double x){ |
判定一个数是否是素数
1 | public static boolean isPrime(int N){ |
计算平方根(牛顿迭代法)
1 | public static double sqrt(double c){ |
计算直角三角形的斜边
1 | public static double hypotenuse(double a, double b){ |
计算调和级数
1 | public static double H(int N){ |

