概观 java 的面向对象

这一篇文章有非常多的错误……或许将来得“重构”。

Java 的面向对象只允许单继承,这方便了系统的设计,但是需要注意的是,这是不符合现实世界的规律的,现实世界是“多继承”的,比如水,它是液体,氢氧化合物,解渴的东西……

类和实例的初始化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
class Father {
private int a = 1; // instance field initialization
private int t;
private static int b = 10; // object field initialization
static {
System.out.println("father's static init block");
}
{
System.out.println("father's instance init block");
}
public Father() {
System.out.println("father's constructor");
}

}
class Son extends Father {
public static int test2 = 3;

static {
System.out.println("son's static init block");
test1 = 2;
test2 = 4;
}
public static int test1 = 1;
{
System.out.println("son's instance init block");
t = 2;
}
public int t = 1;
public Son() {
System.out.println("son's constructor");
}
public static void sayHello() {
System.out.println("hello!");
}
}
public class Main {
public static void main(String[] args) {
System.out.println("world!");
System.out.println(Son.test1);
System.out.println(Son.test2);
Son.sayHello();
System.out.println("");
System.out.println(new Son().t);
}
}

关于对象(object)和实例(instance),可以认为对象是更为广泛的概念,是同上下文无关的,而实例则是相对的,实例必须是某个类(class)的实例,而对象可以不指一个特定类的对象。

域(field)可以认为是类的属性,变量,成员,etc..

上面的代码输出结果如下

1
2
3
4
5
6
7
8
9
10
11
12
world!
father's static init block
son's static init block
1
4
hello!

father's instance init block
father's constructor
son's instance init block
son's constructor
1

可见,类的静态初始化 (static initialization) 和静态初始化块 (static initialization block) 在第一次调用类的方法或变量(无论是实例方法/变量还是静态方法/变量)时同时进行。首先是父类,然后是子类。其调用的顺序同上下文相关(见 test1 和 test2)……这两个不应该同时使用。

然后是类的实例化,首先是父类的对象初始块 (object initialization block) 和对象域初始化(object field initialization)同时进行(见变量 t),就如同上一段的静态、**。然后是父类的构造器。然后是子类的对象初始化块和对象域初始化,最终是子类构造器。

总之,当我们第一次创建一个对象的实例时,首先是类的静态初始化过程(如果之前没有初始化过),首先是父类,然后是子类;然后是类的实例初始化和构造过程,一样地,首先是父类,然后是子类。

(父类静态初始化->子类静态初始化)->父类实例初始化->父类构造器->子类实例初始化->子类构造器

多态

多态(polymorphism)指一个对象变量能够指示多种实际类型的现象。比如,假设 neko 是 animal 派生的子类,则一个 neko 的实例既能够指示 animal(它的超类),也能够指示 rabbit。而其能够自动选择调用哪个方法,则叫动态绑定(dynamic binding)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
class Animal {

public void say() {
StdOut.println("wuwu");
}
}

class Neko extends Animal {
public void say() {
StdOut.println("nyanya");
}

public void niconiconi() {
StdOut.println("niconiconi");
}
}

public static void main(String[] args) {
Animal[] animals = new Animal[2];
animals[0] = new Animal();
animals[1] = new Neko();
animals[0].say(); // "wuwu"
animals[1].say(); // "nyanya"

Animal neko = new Neko(); // neko 究竟是啥类型,得看它调用哪个构造器。但是如此使用的话 neko 就不能使用 Neko 类独有的方法和变量了
neko.say(); // "nyanya"
Animal refer = neko;
refer.say(); // "nyanya" 这里就是动态绑定喽?
neko.niconiconi();//报错!显示这个方法不是 Animal 的
((Neko)neko).niconiconi(); // 现在它能了

Animal dog = new Animal();
((Neko)dog).say(); // 报错!父类不能强转成子类,那上面的喵喵为什么可以呢?因为它本来就是引用的子类变量,只不过是父类类型而已。并且这个错误是 runtime 的!显然,对于 public 方法,是动态绑定的
}
}

可以看到,选择调用哪个 say 方法是动态绑定的,看变量的实际类型。比如,这里定义 neko 是 Animal 类型(这里,Animal 是 neko 的声明类型),却给他引用了一个 Neko 变量,它的 say 被 JVM 动态绑定到了 Neko 的类方法上。同时,它所能调用的方法是受其声明类型所限制的,如果要调用的方法(或类成员)不在超类的域内,则会出现编译错误,必须将该类强制转换为其实际类型才能调用其独有的方法

同时,超类数组能够存储子类,但是子类数组不能存储超类。比如,动物数组可以存储兔子,而兔子数组不能存储动物,这是很容易理解的,因为动物不全是兔子。一个类型的数组不应该存储超出该类型范畴的类型(这里的范畴不是哲学上的范畴,而是“用某种概念界定的范围”)。

抽象类

当某个类中存在有含 abstract 关键字的方法,则该类为抽象类(也可以定义一个类为抽象类,即使其不含抽象方法),这样的方法称为抽象方法,它不需要提供实现(这是子类的活)。抽象类无法被实例化(但是可以有构造器)。继承抽象类的类可以不定义完其超类的所有抽象方法(如此则这个类也是抽象类)。Java 提供的数据结构中,如 map,list,set 等都是抽象类,HashMap,LinkedList,HashSet 等则是它们的子类。

抽象类虽然不能实例化,但是能够作为声明类型,如——

1
Set<Integer> s = new HashSet<Integer>();

这样是合法的。

关于抽象类,作为一个其他领域的例子,哲学上的物质范畴同抽象类是比较接近的,没有脱离具体事物单独存在的物质本身,一切物质都是具体的事物。可是一切物质又都有层次性,结构性……人的概念也是一样,人既具有自然性,也具有社会性,就后者来说,没有脱离一定阶级地位的人。因此,在讨论社会性的人的时候,它也是抽象的。

抽象类或是接口可以认为是抽象层的东西,根据面向对象的设计原则,应当尽量面向抽象层编程——这表现在声明类型应当为接口或抽象类。

反射

反射库 (reflection library) 提供了一个工具库,以编写动态操纵 Java 代码的程序。这种在运行时分析类的能力就是反射。

反射机制可以用来——

  • 在运行时 (runtime) 分析类
  • 在运行时获取对象的实际类型

Class 类

Java 运行时环境为所有对象都维护一个“运行时类型信息”,JVM 通过它来确定对象的实际类型,选择相应的方法。

Class 类就是这个类型信息,一个 Class 对象代表一个类型。每个类型都有代表它的 Class 对象,即使这个类型不是一个类。Class 对象可以用如下方法获得——

1
2
3
4
5
6
7
8
9
Integer a = 1;
System.out.println(a.getClass());
System.out.println(int.class); // 就像 static 的变量……不过 int 并非是一个类就是了
System.out.println(int[].class);
try {
System.out.println(Class.forName("java.lang.Integer"));
} catch (ClassNotFoundException e) { // 受检异常,只能说很丑陋
e.printStackTrace();
}

其输出是

1
2
3
4
class java.lang.Integer
int
class [I // int 数组是这样的
class java.lang.Integer

Class 有一个很有用的方法 newInstance,它动态创建一个类的实例(酷!)。这个方法只能使用无参构造器,如果要使用带参构造器,则使用 Constructor 类的 newInstance 方法。

1
Object newInt = Class.forName("java.lang.Integer").newInstance();

java.lang.reflect

反射机制最重要的内容是——检查类的结构

java.lang.reflect 包中提供了三个类——Field, Method 和 Constructor,它们用于描述类的域,方法和构造器。不仅能通过它们来检查类的结构,还能通过它们来动态访问其域,方法,构造器(动态创建类的实例)。

接口

接口 (interface) 是对类的一组需求描述,它们要遵从接口描述的统一格式进行定义(也因此可以统一地被使用,最典型的就是 Comparable 接口了,实现 (implement) 了这个接口的类都可以使用 Arrays.sort 方法进行排序,就像某种标准化的协议一样。

同时,接口可以被作为声明类型或强制转换的结果,就如抽象类一样。instanceof 可以查看类是否实现了这个接口。

接口内可以增加静态方法和静态常量,接口方法可以被提供一个默认 (default) 实现。

lambda 表达式和函数式接口

lambda 表达式的实质是匿名实现类。

lambda 表达式为 Java 提供了简易的函数式编程能力。Java 的 lambda 表达式实质上是一种函数式接口(这是为了语言的简单性,一致性)。

函数式接口指的是只有一个抽象方法的接口。函数式接口是能实例化的(一切接口都能够作为声明类型,但是不能作为实际类型),但是需要在其后跟随代码块(或者 lambda 表达式)来实现其抽象方法。

lambda 一个常用的地方是排序方法中的自定义比较器——

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Arrays.sort(arr, (a, b) ->  b.compareTo(a));
Arrays.sort(arr, new Comparator<Integer>() {
public int compare(Integer o1, Integer o2) {
return o2.compareTo(o1);
}
});

Comparator<Integer> a = new Comparator<Integer>() { //可以实例化!
@Override
public int compare(Integer o1, Integer o2) {
return o2.compareTo(o1);
}
};
Function<String, Integer> toInteger = s -> Integer.valueOf(s); // 可以使用 lambda 表达式进行实例化

但是 lambda 表达式终究要被转换为接口实例,它不能直接赋值给一个变量(因为无法推断其要转换为哪个接口,而且 Java 没有提供函数类型)。

Java 提供了:: 运算符,能够将方法转换为 lambda 表达式(从而转换为函数式接口),这种形式叫做方法引用。当然,前提是接受参数和返回类型应当与函数式接口中定义的抽象方法一致。

:: 运算符的形式为——

  • Class::staticMethod
  • Object::instanceMethod
  • Class::instanceMethod

第三种情况中,第一个参数会成为方法的目标。比如 String::compareToIgnoreCase 转换为 (x, y) -> x.compareToIgnoreCase(y)。

this 和 super 也是可以使用的。lambda 表达式中的 this 指的是其所处对象,而非该函数式接口实例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class HHW {
public static boolean isNegative(int i) {
return i < 0;
}
public boolean isNegative_instance(int i) { // 不使用 static 在语义上没有任何意义,此处仅为展示
return i < 0;
}
}
List<Integer> a = new ArrayList<>();
HHW t = new HHW();

// 下面四种都是合法的
a.removeIf(new Predicate<Integer>(){ // 其接受一个 T,返回一个布尔值
@Override
public boolean test(Integer t) {
return t < 0;
}
});
a.removeIf(i -> i < 0);
a.removeIf(HHW::isNegative); // 等同于 i -> HHW.isNegative(i)
a.removeIf(t::isNegative_instance); // 等同于 i -> t.isNegative_instance(i)

与此相似的还有构造器引用,形如——

  • Class::new

其调用该 Class 的构造器,具体是哪一个构造器取决于上下文。

这里写个适合静态数组的 foreach——

1
2
3
4
5
6
7
8
9
10
11
12
public class HHW {
public static <T> void foreach(T[] arr, Consumer<T> c) {
for(T i : arr)
c.accept(i);
}
public static void main(String[] args) {
Integer[] arr = {1,2,3,4,5};
foreach(arr, (i) -> {
System.out.print(i + " ");
});
}
}

lambda 表达式给 Java 提供了闭包功能,即其可以捕获 (captured) 表达式外的变量,这里可以将 lambda 表达式看作一个实例对象,捕获的变量成为该对象的实例变量,并被其维护。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public static void repeatMessage(String text, int delay) {
int a = 1;
ActionListener listener = event -> {
a++; // 这样就要报错了,a 必须为 final
System.out.println(text); // 在这里,text 被 lambda 表达式捕获了
};
new Timer(delay, listener).start;
}
public static void repeatMessage(String text, int delay) {
for (int i = 0; i < 10; i++)
ActionListener listener = event -> {
System.out.println(i + " " + text); // 也要报错
};
new Timer(delay, listener).start;
}

但是,Java 的闭包中的自由变量必须是实际上的最终变量(effectively final),意思是其在初始化之后不会再被赋新值,否则在并发时可能出现问题。

类的加载

类的加载是类加载器 (Class Loader) 执行的,其有如下步骤——加载链接初始化

似乎类的初始化不是一定进行的?

加载

类的加载就是 Java 字节码(java 文件编译后的 class 文件中的内容就是字节码)加载到内存中,并且其静态数据(静态变量,静态方法,常量池(指类名,方法名,成员名等?),代码)被转换为运行时的数据结构,存储在方法区 (method area,一种特殊的堆)中,然后在堆(heap)生成对应这个类的 java.lang.Class 对象。Class 对象是独一无二的,一个类有且只有一个 Class 对象。

有 Class 对象的类型有——

  • 接口
  • 数组
  • 注解
  • void
  • 包装类
  • ……

链接

链接过程是 Java 二进制代码(指的是加载步骤中,字节码转换成的运行时数据结构吗?)合并到 JVM 的运行状态的过程。

首先其将会检查类的结构,确保安全性问题。

然后它为类变量(static)分配内存,并设置其初始值(对于对象,应当是 null,对于基本类型,应当是 0 或 false 等)。同时,对于静态常量(static final),其将直接被初始化,存入常量池。(常量池应当只存储 static 常量,其逻辑上存在于方法区,实际上存在于堆)这些内存都在方法区分配。

最后,它将常量池内的符号引用(变量名)替换为直接引用(地址)的过程。

初始化

初始化过程将首先调用类构造器<clinit>(),其将合并 static 代码的赋值操作和 static 代码块并执行。

它将首先初始化该类的父类,如果其父类并未被初始化的话。

类在什么时候加载

类的初始化(初始化则必然要加载)发生在类的主动引用时。

主动引用的情况有——

  • main 所在的类最先被初始化
  • new 一个类
  • 调用类的静态成员(除了 final)和静态方法
  • 使用 Reflect 类对类进行反射调用
  • 将被初始化的类的父类

在被动引用时,类不会初始化,其情况有——

  • 引用一个类的静态域,只有真正声明这个静态域的类才会被初始化(比如子类引用父类的静态域,只有父类初始化)
  • 定义类的引用数组
  • 引用类的静态常量(其在链接阶段就存在常量池中)


本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 协议 ,转载请注明出处!