面向对象程序
面向对象程序设计(简称 OOP
,Object Oriented Programming
) 是当今主流的程序设计范型 。
面向对象的程序由对象组成, 每个对象包含对用户公开的特定功能部分和隐藏的实现部分。
面向对象包含以下 3 个最重要的特性:
- 封装
- 继承
- 多态
类
类 ( class
) 是构造对象的模板或蓝图。
你可以将类想象成制作枪具的设计图, 将对象想象为枪的实体。
由类构造(construct
) 对象的过程称为创建类的实例(instance
)。
对象
Java 中的一切都被视为对象。
尽管一切都看作对象,但操纵的标识符(变量)实际上是对象的一个“引用”(reference
)。
你可以将这一情形想象成在用遥控器(引用)操纵电视机(对象),只要握住这个遥控器,就能保持与电视机的连接。当你想改变频道或减少音量时,实际操纵的是遥控器(引用),再由遥控器来操纵电视机(对象)。若你想在房间里四处走走的同时仍能遥控电视机,那么只需携带遥控器(引用)而不是电视机(对象)。
此外,即使没有电视机,遥控器亦可独立存在。也就是说,你必须拥有一个引用,但并不一定需要有一个对象与它关联。
若想操纵一个词或句子,则可以创建一个String
引用:1
String s;
但这里所创建的只是引用,并不是对象。
若此时向s
发送一个消息,就会返回一个运行时错误,因为此时s
实际上没有与任何事务相关联(即没有电视机)。
所以,一种安全的做法是:创建一个引用的同时便进行初始化:1
String s = "abcd";
当然,字符串对象有点特殊,它用带引号的文本进行初始化。通常对其他对象而言,一般是使用构造器来初始化。
要想使用 OOP, —定要清楚对象的三个主要特性:
- 对象的行为(
behavior
) ——可以对对象施加哪些操作,或可以对对象施加哪些方法? - 对象的状态 (
state
) ——当施加那些方法时对象如何响应? - 对象标识(
identity
)—— 如何辨别具有相同行为与状态的不同对象?
同一个类的所有对象实例,由于支持相同的行为而具有家族式的相似性。对象的行为是用可调用的方法定义的。
此外, 每个对象都保存着描述当前特征的信息,这就是对象的状态。
对象的状态可能会随着时间而发生改变, 但这种改变不会是自发的。 对象状态的改变必须通过调用方法实现 (若不经过方法调用就可以改变对象状态, 只能说明封装性遭到了破坏 )。
但是,对象的状态并不能完全描述一个对象,每个对象都有一个唯一的身份。
例如, 在一个订单处理系统中,任何两个订单都存在着不同之处,即使所订购的货物完全相同也是如此。需要注意对象作为一个类的实例, 每个对象的标识永远是不同的,状态常常也存在着差异。
对象的这些关键特性在彼此之间相互影响着。
比如,对象的状态影响着它的行为(若一个订单“ 已送货” 或“ 已付款”, 就应该拒绝调用具有增删订单中条目的方法。反过来, 如果订单是“空的”, 即还没有加入预订的物品, 这个订单就不应该进入“已送货” 状态)
类间关系
在类之间, 最常见的关系有:
- 依赖(
uses-a
) - 聚合(
has-a
) - 继承(
is-a
)
依赖
依赖(dependence
),即uses-a
关系,是一种最明显的、最常见的关系。
例如,订单类使用账户类是因为订单对象需要访问账户对象查看信用状态。 但是商品类不依赖于账户类, 这是因为商品对象与客户账户无关。
因此,若一个类的方法操纵另一个类的对象,我们就说一个类依赖于另一个类。
程序中应尽可能地将相互依赖的类减至最少。若类 A 不知道 B 的存在, 它就不会关心 B 的任何改变(这意味着 B 的改变不会导致 A 产生任何bug
)。
用软件工程的术语来说, 就是让类之间的耦合度最小。
聚合
聚合(aggregation
) 即has-a
关系, 是一种具体且易于理解的关系。
例如, 一个订单对象包含一些商品对象。
聚合关系意味着类 A 的对象包含类 B 的对象。
继承
继承(inheritance
), 即is-a
关系, 是一种用于表示特殊与一般关系的。
如人类对象继承动物对象,可以说人类对象继承动物对象,人类对象为子类,动物对象为父类。
注
:Java 中Object
对象是任何对象的父类。
静态域和静态方法
Java中的main
方法都被标记为static
修饰符,下面讨论一下。
静态域(变量)
若将域(变量)定义为static
, 则每个类中只有一个这样的域(变量)。而对于其他所有不是static
修饰的实例域(变量),每一个对象都有自己的一份拷贝。
换而言之,类变量是根据该类创建的所有对象所共有的,所有对象都共享这一个类变量。
例如,对某个大学的学生而言:1
2
3
4public class Student{
public static String schoolName = "某个学校";
...
}
对于每个创建的 Student 对象而言,都是同一个学校的,所以它们的学校名是一样的,即使没有一个学生对象,静态变量schoolName
也存在。 它属于类, 而不属于任何独立的对象。
因此,即使创建了 1000 个学生对象,也只有一个静态的类变量schoolName
。
静态常量
静态变量使用得比较少, 但静态常量却使用得比较多。
例如, 在 Java 自带的Math
类中定义了一个静态常量:1
2
3public final class Math {
public static final double PI = 3.14159265358979323846;
}
在程序中, 可以采用Math.PI
的形式获得这个常量。
若关键字 static
被省略,PI
就变成了 Math
类的一个实例域(变量)。 需要通过 Math 类的对象访问 PI
并且每一个Math
对象都有它自己的一份 PI 拷贝。
静态方法
静态方法是一种不能向对象实施操作的方法。
例如,Math
类的 pow
方法就是一静态方法。
表达式Math.pow(x,a);
将计算幂x
的a
次方。
在运算时,不使用任何 Math 对象,换句话说,没有隐式的参数,所以可认为静态方法是没有this
参数的方法。
main 方法
需要注意, 不需要创建对象来调用静态方法。例如, 不需要构造 Math
类对象就可以调用 Math.pow
。
同理,main
方法也是一个静态方法。main
方法不对任何对象进行操作。
事实上,在启动程序时还没有任何一个对象。静态的main
方法就将执行并创建程序所需要的对象。
方法参数
首先回顾一下在程序设计语言中有关将参数传递给方法(或函数)的一些专业术语:
- 按值调用:表示方法接收的是调用者提供的值
- 按引用调用:表示方法接收的是调用者提供的变量地址
一个方法可以修改传递引用所对应的变量值, 而不能修改传递值调用所对应的变量值。
Java 程序设计语言总是采用按值调用。
换而言之, 方法得到的是所有参数值的一个拷贝!拷贝!!拷贝!!!,特别是, 方法不能修改传递给它的任何参数变量的内容。
例如,考虑下面的一个方法,该方法试图将一个参数值增加至3
倍:1
2
3
4// doesn't work
public static void tripieValue(double x) {
x = 3 * x;
}
下面调用这个方法:1
2double percent = 10;
tripieValue(percent) ;
不过, 理想中的30
并未出现,调用这个方法后,percent
的值还是10
。
为什么呢?
下面对其执行过程具体分析一下:
x
被初始化为percent
值的一个拷贝 (也就 是10
)x
被乘以3
后等于30
,但是percent
的值仍为10
(如图所示)- 该方法结束之后, 参数变量
x
不再使用
然而, 方法参数共有两种类型:
- 基本数据类型
- 对象引用类型
虽然一个方法不可能修改一个基本数据类型的参数,但若是用对象引用作为参数就不一样了,我们可以很容易地利用下面这个方法将一个雇员的薪金提高两倍:1
2
3
4
5
6
7
8// works
public static void tripieSalary(Employee x) {
x.raiseSa1ary(200);
}
// 当调用
Employee harry = new Employee();
tripieSalary(harry) ;
具体的执行过程为:
x
被初始化为harry
值的拷贝, 这里是一个对象的引用;raiseSalary
方法应用于这个对象引用。x
和harry
同时引用的那个Employee
对象的薪金提高了 200%- 方法结束后, 参数变量
x
不再使用。当然,对象变量harry
继续引用那个薪金增至 3 倍的雇员对象(如下图)
可以看到,实现一个改变对象参数状态的方法并不是一件难事。 理由很简单,方法得到的是对象引用的拷贝,对象引用及其他的拷贝同时引用同一个对象。
所以有些程序员认为 Java 程序设计语言对对象采用的是引用调用,但实际上,这种理解是不对的。
下面给出一个反例来详细地阐述一下这个问题。
首先, 编写一个交换两个雇员对象的方法:
1 | public static void swap(Employee x, Employee y){ |
若 Java 中对象采用的是按引用调用, 那么这个方法就应该能够实现交换数据的效果:1
2
3Employee a = new Employee("Alice", . . .);
Employee b = new Employee("Bob", . . .);
swap(a, b);
但是,方法并没有改变存储在变量a
和b
中的对象引用。swap
方法的参数x
和 y
被初始化为两个对象引用的拷贝,这个方法交换的是这两个拷贝,即x
和y
被交换了。
最终,白费力气。 在方法结束时参数变量x
和y
被丢弃了。 原来的变量 a
和b
仍然引用这个方法调用之前所引用的对象,如下图:
下面总结一下 Java 中方法参数的使用情况:
- 一个方法不能修改一个基本数据类型的参数。
- 一个方法可以改变一个对象参数的状态(变量)
- 一个方法不能让对象参数引用一个新的对象
对象构造
重载
方法重载:指在一个类中定义多个同名的方法,但要求每个方法具有不同的参数的类型或参数的个数。
方法重载原则如下:
- 方法名必须相同
- 参数类型或个数必须不同
注
:在重载中,方法的返回类型、修饰符可以相同,也可不同
换而言之:
- 若方法名相同且参数个数不同,无论参数类型如何,必然重载
- 若方法名相同且参数个数相同,参数类型或参数顺序不同时才为重载。
重写
方法重写是存在子类与父类之间的,子类定义的方法与父类中的方法具有相同的方法名字,相同的参数列表和相同的返回类型,方法的修饰权限不能缩小;
重写方法的规则(遵循两同两小一大原则):
- 方法签名相同(方法名相同,参数列表相同);
- 子类返回类型小于等于父类方法返回类型(返回类型是基本类型和 void 的要和父类保持一致,只有引用类型返回类型小于等于父类的);
- 子类抛出异常小于等于父类方法抛出异常。例如:父类的一个方法申明了一个受查异常;
IOException
,在重写这个方法是就不能抛出Exception
,只能抛出IOException
的子类异常,可以抛出非受查异常; - 子类访问权限大于等于父类方法访问权限(
public>protected>default>private
)
默认域初始化
若在构造器中没有显式地给成员变量赋值, 那么其将被自动地赋为默认值:
- 数值则为
0
- 布尔值则为
false
- 对象引用则为
null
。
无参构造器
很多类都包含一个无参数的构造函数,对象由无参数构造函数创建时, 其状态会设置为适当的默认值。
若在编写一个类时没有编写构造器, 那么系统就会提供一个无参数构造器。这个构造器将所有的实例域设置为默认值。
若类中提供了至少一个构造器,但是没有提供无参数的构造器, 则在构造对象时若 没有提供参数就会被视为不合法。
调用另一个构造器
关键字 this
引用方法的隐式参数。 然而,这个关键字还有另外一个含义。
若构造器的第一个语句形如 this(...)
, 这个构造器将调用同一个类的另一个构造器。
初始化块
前面写过两种初始化数据域的方法:
- 在声明中赋值;
- 在构造器中赋值
实际上,Java 还有第三种机制,称为初始化块。
在一个类的声明中, 可以包含多个代码块。只要构造类的对象,这些块就会被执行。例如:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19class Employee{
private static int nextld;
private int id;
private String name;
private double salary;
//初始化块
{
id = nextld;
nextld++;
}
public Employee(String n, double s){
salary = s;
}
public Employee() {
name = "";
salary = 0;
}
...
}
在这个示例中, 无论使用哪个构造器构造对象,id
变量都在对象初始化块中被初始化。首先运行初始化块,然后才运行构造器的主体部分。
这种机制不是必需的,也不常见。通常会直接将初始化代码放在构造器中。
final 应用场景
- 对类:若一个类被声明为
final
,意味着它不能再派生出新的子类,不能作为父类被继承。因此一个类不能既被声明为abstract
的,又被声明为final
的。 - 对变量:将变量被声明为
final
,可以保证其在使用中不被改变。声明为final
的变量必须在new一个对象时初始化(即只能在声明变量或构造器或代码块内初始化),而在以后的引用中只能读取,不可修改。 - 对方法:将方法声明为
final
,可以保证其在使用中不被改变。被声明为 final 的方法只能使用,不能在子类中覆盖(重写)。
包
Java允许使用包 (package
) 将类组织起来。 借助于包可以方便地组织自己的代码, 并将自己的代码与别人提供的代码库分开管理。
使用包的主要原因是确保类名的唯一性。假如两个程序员不约而同地建立了 相同名字的类,只要将这些类放置在不同的包中, 就不会产生冲突。
类的导入
类的导入有两种方式:
- 在每个类名之前添加完整的包名:
java.time.LocalDate
- 使用
import
语句导人一个特定的类或者整个包:import java.util.*;
推荐使用第二种。
静态导入(了解)
import
语句不仅可以导人类, 还增加了导人静态方法和静态域的功能,如:1
import static java.lang.System.*;
这样在代码中就可以直接使用System
类的静态方法和静态域, 而不必加类名前缀:1
2out.println("Hello World");
exit(0);
包作用域
Java中的修饰符共有 4 个,各自范围不同:
类别\范围 | 类内部 | 本包 | 子类 | 外部包 |
---|---|---|---|---|
public | 1 | 1 | 1 | 1 |
protected | 1 | 1 | 1 | 0 |
default | 1 | 1 | 0 | 0 |
private | 1 | 0 | 0 | 0 |
注
:default
修饰符默认不写。
参考
- Cay S. Horstmann. Java 核心技术卷一 [M]. 机械工业出版社,2016