类型信息
运行时类型信息使你可以在程序运行期发现和使用类型信息。
Java是如何让我们在运行时识别对象和类的信息呢?主要有两种方式:一种是“传统的”RTTI(Run-time Type Information),它假定我们在编译时已经知道所有类型。另一个是反射机制,它允许我们在运行时发现和使用类的信息。
我们看一个例子:
import java.util.Arrays;
import java.util.List;
/**
* @author Tom-Chen
* Time : 2022-01-12 11:14
*/
abstract class Shape{
void draw(){
System.out.println(this + ".draw()");
}
abstract public String toString();
}
class Circle extends Shape{
@Override
public String toString() {
return "Circle";
}
}
class Square extends Shape{
@Override
public String toString() {
return "Square";
}
}
class Triangle extends Shape{
@Override
public String toString() {
return "Triangle";
}
}
public class Shapes {
public static void main(String[] args) {
List<Shape> shapeList = Arrays.asList(new Circle(), new Square(), new Triangle());
for (Shape shape : shapeList) {
shape.draw();
}
}
}
//output
Circle.draw()
Square.draw()
Triangle.draw()
在这个例子中我们可以看到,基类(父类)中都含有draw()方法,它通过传递this参数给System.out.println()间件的使用了toString打印类标识符。需要注意的是,toString方法是abstract,这样就强制要求继承者覆写这个方法,也可以防止对无格式化的Shape的初始化。如果某个对象出现在字符串表达式中(只要你将它进行变为字符串的操作),那么toString()就会被自动调用,用来生成表示该对象的字符串。每个派生类(子类)都需要覆写toString()方法,这样draw()方法在不同的情况下就会打印出不同的消息,这种也就是多态的展现。
在Java中,所有的类型转化都是发生在运行时进行正确性检查的。如同上述例子,当把Square对象放入List< Shape>中,Square对象就会向上转型变为Shape。但向上转型的同时也丢失了它原本的类型。对于数组而言它们都是Shape对象。当从数组中取处元素的时候,数组(这种容器实际上会将所有事物都当作object持有)会自动将结果转型回Shape。这也就是RTTI最基本的使用形式,也是RTTI名字的含义:在运行期间,识别一个对象的类型。
Class对象:
类型信息是由称为Class对象的特殊对象完成的,它包含了与类有关的信息。Java使用Class对象来执行RTTI,除去类似转型这样的操作,Class类还拥有大量使用RTTI的其它方式。每个类都有一个Class对象,每当编写并且编译了一个新类,就会产生一个Class对象(更恰当的说是保存在一个同名的.class文件),运行这个程序的Java虚拟机将使用“类加载器”的子系统来生成这个类的对象。类加载器实际上可以包含一条类加载器链,但只有一个
原生类加载器
,它是JVM实现的一部分。原生类加载器加载的是像Java API类这种可信类,它们通常是从本地盘加载的。在这条链中,通常不需要添加额外的类加载器,当然如果你有特殊的要求(比如以某种特殊的方式加载类,来支持Web服务器应用或者在网络中下载类),那么你可以用另一种方式挂接额外的类加载器。
所有的类都会在第一次使用的时候,动态的加载到JVM中。当程序创建第一个对类的静态成员引用时,就会加载这个类。这个证明即使构造器没有static关键字但是依旧时类的静态方法。使用new操作符创建类的新对象也会被当作对类的静态成员的引用。因此,Java程序在它开始运行之前并不是完全被加载的,其各个部分实在必需时才加载的。这种动态加载的行为是如C++这种静态加载语言很难或不可能复制的。我们通过一个小例子来证明一旦某个类的Class对象被载入内存,它就会用来创建这个类的所有对象。
/**
* @author Tom-Chen
* Time : 2022-01-12 12:32
*/
class Candy{
static {
System.out.println("Loading Candy");
}
}
class Gum{
static {
System.out.println("Loading Gum");
}
}
class Cookie{
static {
System.out.println("Loading Cookie");
}
}
public class SweetShop {
public static void main(String[] args) {
System.out.println("inside main");
new Candy();
System.out.println("After creating Candy");
try {
Class.forName("Gum");
} catch (ClassNotFoundException e) {
System.out.println("Couldn't find Gum");
}
System.out.println("After Class.forName(\"Gum\")");
new Cookie();
System.out.println("After creating Cookie");
}
}
//output
inside main
Loading Candy
After creating Candy
Loading Gum
After Class.forName("Gum")
Loading Cookie
After creating Cookie
这个例子中,每个类(Candy、Cookie等)都有一个static子句,这个子句在类第一次加载的时候执行,就会把相应的信息打印出来,这就让我们知道了这个类什么时候被加载了。从输出中可以看到,static实在类初始化的时候加载的,但是Class对象仅在需要的时候才会被加载。
Class.forName(“Gum”);这个方法是Class类(所有的Class对象都属于这个类)的一个static成员。Class对象和其它对象一样,我们可以获取并操控它的引用(这便是类加载器的作用)。而forName()是取得Class对象的引用的一种方法。但是这种获取Class对象引用的方法有弊端,因为它是用一个包含目标类的文本名的字符串作为输入参数,如果通过输入的字符串找不到你要加载的类,他就会抛出异常ClassNotFoundException。
Java还提供了另外一种方式来生成对Class对象的引用,这就是
类字面常量
。其实就是类名.class。这样更安全并且更简单,他在编译时就会进行检查(也就不需要放在try语句块中),它根除了对forName方法的调用,所以也更高效。它不仅可以应用于普通的类也可以应用于接口、数组以及基本数据类型。还有一点很有趣,当使用“.class”来创建对Class对象的引用的时候,不会自动的初始化该Class对象。想使用它的话需要做以下三个准备步骤:1.加载,这个是由加载器执行的。该步骤将查找字节码,并从这些字节码创建一个Class对象。2.链接。在链接阶段将验证类中的字节码,为静态域分配空间,并且如果必需的话,将解析这个类创建的对其他类的所有引用。3.初始化。如果该类具有超类,则对其初始化,执行静态初始化器和静态初始化块。
初始化被延迟到了对静态方法(构造器是隐式的是静态的)或者非常数静态域进行首次引用时才执行:
import java.util.Random;
/**
* @author Tom-Chen
* Time : 2022-01-12 14:22
*/
class Initable{
static final int staicFinal = 47;
static final int staicFinal2 = ClassInitialization.rand.nextInt(1000);
static {
System.out.println("Initializing Initable");
}
}
class Initable2{
static int staticNotFinal = 147;
static {
System.out.println("Initializing Initable");
}
}
class Initable3{
static int staticNotFinal = 74;
static {
System.out.println("Initializing Initable3");
}
}
public class ClassInitialization {
public static Random rand = new Random(47);
public static void main(String[] args) throws ClassNotFoundException {
Class initable = Initable.class;
System.out.println("After creating Initable ref");
System.out.println(Initable.staicFinal);
System.out.println(Initable.staicFinal2);
System.out.println(Initable2.staticNotFinal);
Class initable3 = Class.forName("Initable3");
System.out.println("After creating initable3 ref");
System.out.println(Initable3.staticNotFinal);
}
}
//output
After creating Initable ref
47
Initializing Initable
258
Initializing Initable2
147
Initializing Initable3
After creating initable3 ref
74
初始化有效的实现了尽可能的“惰性”。通过.class获得类的引用不会引发初始化,但是Class.forName()立即就进行了初始化,就像获取Initable3引用一样。
如果一个static final的值是“编译期常量”,就像Initable.staticFinal那样,那么这个值不需要对Initable初始化就可以被读取。但是,如果只是将一个域设置为static和final的,还不足以确保这种行为,例如,对Initable.staticFinanl2的访问将强制进行类的初始化,因为他不是一个编译期常量。
如果一个static域不是final的,那么在对它访问时,总是要求在它被读取前,要先进行链接(为这个域分配存储空间)和初始化(初始化该空间),就像对Initable2.staticNotFinal的访问中看到的那样。
到目前为止,我们已知的RTTI的形式包括:
1)传统的类型转换,由RTTI确保类型转换的正确性,如果执行了一个错误的类型转换,就会抛出一个ClassCastException异常。
2)代表对象的类型的Class对象。通过查询Class对象可以获取运行时所需的信息。
3)关键字 instanceof。它返回一个布尔值,告诉我们对象是不是一个特定类型的实例。 例如:
if(x instanceof Dog)((Dog)x).bark();
在进行向下转型前,如果没有信息可以告诉你这个对象是什么类型,那么使用instanceof就很重要,否则会得到一个ClassCastException异常;
反射:
如果不知道某个对象的确切信息,RTTI可以告诉你。但是有一个限制:这个类型在编译时必须已知,这样才能使用RTTI来识别它,并利用这些信息做一些有用的事。反射提供了一种机制——用来检查可用的方法,并返回方法名。Java通过JavaBeans提供了基于构件的编程架构。人们想要在运行时获取类的信息还有另一个动机,便是希望提供在跨网络的远程平台上创建和运行对象的能力。这被称为
远程方法调用
(RMI),它允许将一个Java程序将对象分布到多台机器上。通常你不需要直接使用反射工具,但是他们在你需要创建更加动态的代码时会很有用。反射在Java中是用来支持其它特性的,例如对象序列化和JavaBean。
动态代理:
代理是基本的设计模式之一,它是你为了提供额外的或不同的操作,而插入的用来代替“实际”的对象的对象。这些操作通常涉及与“实际”对象的通信,因此代理通常充当着中间人的角色。下面是一个用来展示代理结构的简单示例:
/**
* 展示代理结构示例
* @author Tom-Chen
* Time : 2022-01-12 15:36
*/
interface Interface{
void doSomething();
void somethingElse(String arg);
}
class RealObject implements Interface{
@Override
public void doSomething() {
System.out.println("doSomething");
}
@Override
public void somethingElse(String arg) {
System.out.println("somethingElse : " + arg);
}
}
class SimpleProxy implements Interface{
private Interface proxied;
public SimpleProxy(Interface proxied){
this.proxied = proxied;
}
@Override
public void doSomething() {
System.out.println("SimpleProxy doSomething");
proxied.doSomething();
}
@Override
public void somethingElse(String arg) {
System.out.println("SimpleProxy somethingElse : " + arg);
proxied.somethingElse(arg);
}
}
public class SimpleProxyDemo {
public static void consumer(Interface iface){
iface.doSomething();
iface.somethingElse("bonobo");
}
public static void main(String[] args) {
consumer(new RealObject());
consumer(new SimpleProxy(new RealObject()));
}
}
//output
doSomething
somethingElse : bonobo
SimpleProxy doSomething
doSomething
SimpleProxy somethingElse : bonobo
somethingElse : bonobo
因为consumer()接受的Interface,所以它无法知道正在获取的到底是Real Object还是SimpleProxy,因为这两者都实现了Interface。但是SimpleProxy已经被插入到客户端和RealObject直接,因此它会执行操作,然后调用Real Object上相同的方法。
在任何时候,只要你想要将额外的操作从“实际”对象中分离到不同的地方,特别是当你希望能够很容易的做出修改,从没有使用额外操作转为使用这些操作,或者反过来时,代理就显得很有用。Java动态代理比代理的思想更向前迈进了一步,因为它可以动态的创建代理并动态的处理所代理方法的调用。在动态代理上所做的所有调用都会被重定向到单一的调用处理器上,它的工作是揭示调用类型并确定相应的对策。下面使用动态代理重写的SimepleProxyDemo:
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
/**
* 动态代理示例
* @author Tom-Chen
* Time : 2022-01-12 15:56
*/
class DynamicProxyHandler implements InvocationHandler {
private Object proxied;
public DynamicProxyHandler(Object proxied){
this.proxied = proxied;
}
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
System.out.println("***** proxy : " + proxy.getClass() + ", method : " + method + ", args : " + args);
if (args != null){
for (Object arg: args){
System.out.println(" " + arg);
}
}
return method.invoke(proxied,args);
}
}
public class SimpleDynamicProxy {
public static void consumer(Interface iface){
iface.doSomething();
iface.somethingElse("bonobo");
}
public static void main(String[] args) {
RealObject real = new RealObject();
consumer(real);
Interface proxy = (Interface) Proxy.newProxyInstance(Interface.class.getClassLoader(),new Class[]{Interface.class},new DynamicProxyHandler(real));
consumer(proxy);
}
}
//output
doSomething
somethingElse : bonobo
***** proxy : class $Proxy0, method : public abstract void Interface.doSomething(), args : null
doSomething
***** proxy : class $Proxy0, method : public abstract void Interface.somethingElse(java.lang.String), args : [Ljava.lang.Object;@2e0fa5d3
bonobo
somethingElse : bonobo
通过调用静态方法Proxy.newProxyInstance()可以创建动态代理,这个方法需要得到一个类加载器(通常可以从以被加载的对象中获取类加载器然后传递给它),一个你希望该代理实现的接口列表(不能是类或抽象类),以及Invocation Handler接口的实现。动态代理可以将所有调用重定向到调用处理器,因此通常会向调用处理器的构造器传递一个“实际”对象的引用,从而使得调用处理器在执行其中介任务时,可以请求转发。
invoke()方法中传递进来了代理对象,以防你需要区分请求的来源,但是在许多情况下,你并不关心这一点。然而在invoke()内部,在代理上调用方法时需要格外当心,因为对接口的调用将被重定向对代理的调用。
通常,你会执行被代理的操作,然后使用Method.invoke()将请求转发给代理对象,并传入必需的参数。第一眼看好像有些受限,就像你只能执行泛化操作一样。但是,你可以通过传递其它的参数,来过滤某些方法的调用:
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
/**
* @author Tom-Chen
* Time : 2022-01-12 16:19
*/
class MethodSelector implements InvocationHandler {
private Object proxied;
public MethodSelector(Object proxied){
this.proxied = proxied;
}
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
if (method.getName().equals("interesting"))
System.out.println("Proxy detected the interesting method");
return method.invoke(proxied,args);
}
}
interface SomeMethods{
void boring1();
void boring2();
void interesting(String arg);
void boring3();
}
class Implementation implements SomeMethods{
@Override
public void boring1() {
System.out.println("boring1");
}
@Override
public void boring2() {
System.out.println("boring2");
}
@Override
public void interesting(String arg) {
System.out.println("interesting " + arg);
}
@Override
public void boring3() {
System.out.println("boring3");
}
}
public class SelectingMethods {
public static void main(String[] args) {
SomeMethods proxy = (SomeMethods) Proxy.newProxyInstance(SomeMethods.class.getClassLoader(),new Class[]{SomeMethods.class},new MethodSelector(new Implementation()));
proxy.boring1();
proxy.boring2();
proxy.interesting("bonobo");
proxy.boring3();
}
}
//output
boring1
boring2
Proxy detected the interesting method
interesting bonobo
boring3
这里我们只查看了方法名,但是你还可以查看方法签名的其它方面,甚至可以搜索特定的参数值。
注意:使用动态代理来编写一个系统以实现事务,其中,代理在被代理的调用执行成功时(不抛出任何异常)执行提交,而在其执行失败时执行回滚。你的提交和回滚都针对一个外部的文本文件,该文本不再Java异常的控制范围之内。你必须注意操作的原子性。
空对象:
当你使用内置的null表示缺少对象时,在每次使用引用的时候都必须测试其是否为null,这样势必让你的代码不够优雅。有时引入空对象的思想将会很实用,他可以接受传递给他的所代表的对象的消息,但是将返回表示为实际上并不存在任何“真实”对象的值。通过这种方式你可以假设所有的对象都是有效的,而不必浪费编程精力去检查null。空对象最有用的的地方在于它更靠近数据,因为对象表示的是问题空间内的实体。有一个简单的例子,许多系统都会有Person类,而代码中你是没有一个实际的人,因此,通常都是使用一个null引用并测试它。与此不同的是,我们可以使用空对象。但是即使空对象可以响应“实际”对象可以响应的所有消息,你还是需要某种方式去测试它是否为空。要达到这个目的,最简单的方式是创建一个标记接口:
/**
* @author Tom-Chen
* Time : 2022-01-12 17:11
*/
public interface Null {}
这使得instanceof可以探测到空对象,更重要的是,这并不要求你在所有的类中都添加isNull()方法
/**
* @author Tom-Chen
* Time : 2022-01-12 17:12
*/
public class Person {
public final String first;
public final String last;
public final String address;
public Person(String first, String last, String address) {
this.first = first;
this.last = last;
this.address = address;
}
public String toString(){
return "Person : " + first + " " + last + " " + address;
}
public static class NullPerson extends Person implements Null{
public NullPerson() {
super("None", "None", "None");
}
@Override
public String toString() {
return "NullPerson";
}
public static final Person NULL = new NullPerson();
}
}
通常,空对象都是单例,因此这里将其作为静态final实例创建。这可以正常工作,因为Person是不可变的,你除非在构造器中设置它的值。
空对象的逻辑变体是模拟对象和桩。与空对象一样,它们都表示在最终的程序中所使用的“实际”对象。但是,模拟对象和桩都是假扮可以传递实际信息的存活对象,而不是像空对象这样可以成为null的一种更智能化的替代物。
模拟对象和桩之间的差异在于程度不同。模拟对象往往是轻量级和自测试的,通常很多模拟对象被创建出来是为了处理各种不同的测试情况。桩只是返回数据,它通常是重量级的,并且经常在测试之间被复用。桩可以根据它们被调用的方式,通过配置进行修改,因此桩是一种复杂对象,它要做很多事。然而对于模拟对象,如果需要做很多事情,通常会创建大量小而简单的模拟对象。
接口与关键字:
interface关键字的一种重要目标就是允许程序员隔离构件,进而降低耦合性。如果你编写接口那么就可以实现这一目标,但是通过类型信息,这种耦合性还是会传播出去——接口并非是对解耦的一种无懈可击的保障。下面有一个示例,先是一个接口:
/**
* @author Tom-Chen
* Time : 2022-01-12 17:53
*/
public interface A {
void f();
}
```
然后实现这个接口,你可以看到其代码是如何围绕着实际的实现类型潜行的:
```java
/**
* @author Tom-Chen
* Time : 2022-01-12 17:55
*/
class B implements A{
@Override
public void f() {
}
public void g(){}
}
public class InterfaceViolation {
public static void main(String[] args) {
A a = new B();
a.f();
System.out.println(a.getClass().getName());
if (a instanceof B){
B b = (B)a;
b.g();
}
}
}
//output
B
通过使用RTTI,我们发现a是被当作B实现的。通过将其转型为B,我们可以不调用不再A中的方法。
这是完全合法和可接受的,但是你也许并不想让客户端程序员这么做,因为这给了他们一个机会,使得他们的代码和你的代码的耦合程度超过你的期望。也就是说,你可能认为interface正在保护你,但是它没有,在上述的例子中使用B来实现A这一事实是公开有案例的(最出名的就是Windows操作系统,它有一个发布的API,并假设你会对着它编码,另外还有一个未发布的,但是可视的函数集,你可以发现并调用它。为了解决问题,程序员会使用隐藏的API函数,这导致微软不得不把它们当作公共API的一部分来维护)。
一种方案是直接声明,如果程序员决定使用实际的类而不是接口,他们需要自己对自己负责。这在很多情况下可能都是合理的,但“可能”还不够,你也许希望应用更加严格控制。
最简单的方式是对实现使用包访问权限,这样包外部的客户端就不能看到它了:
/**
* @author Tom-Chen
* Time : 2022-01-12 18:08
*/
class C implements A{
public void g(){
System.out.println("public C.g()");
}
@Override
public void f() {
System.out.println("public C.f()");
}
void u(){
System.out.println("package C.u()");
}
protected void v(){
System.out.println("public C.v()");
}
private void w(){
System.out.println("public C.w()");
}
}
public class HiddenC {
public static A makeA(){
return new C();
}
}
在这个包中唯一public的部分,即HiddeC,在被调用时将产生A接口类型的对象,这里有趣之处在于:即使你从makeA()返回的是C类型,你在包的外部依旧不能使用A之外的任何方法,因为你不能在包的外部命名C。
现在你如果试图向下转型为C,则将会被禁止,因为在包的外部没有任何C类型可用:
/**
* @author Tom-Chen
* Time : 2022-01-12 18:08
*/
class C implements A{
public void g(){
System.out.println("public C.g()");
}
@Override
public void f() {
System.out.println("public C.f()");
}
void u(){
System.out.println("package C.u()");
}
protected void v(){
System.out.println("public C.v()");
}
private void w(){
System.out.println("public C.w()");
}
}
public class HiddenC {
public static A makeA(){
return new C();
}
}
//output
public C.f()
C
public C.g()
package C.u()
public C.v()
public C.w()
我们发现通过反射,我们仍旧可以调用所有方法,甚至是私有方法。
看起来没有任何方式可以阻止反射到达并调用那些非公共访问权限的方法。对于域来说,的确如此,就算是private域。但是,final域实际上在遭遇修改时是安全的。运行时系统会在不抛异常的情况下接受任何修改尝试,但是实际上不会发生任何修改。
通常,所有这些违反访问权限的操作并非是最糟糕的事情。如果有人使用这样的技术去调用标识为private或包访问权限的方法(很明显这些访问权限表示它不应该被调用),那么对他们来说,如果你修改了这些方法的某些方面,他们不应该抱怨。另一方面,总是在类中留下后门这一事实,也许可以使得你能够解决某些特定类型的问题,但如果不这么做,这些问题难以解决甚至无法解决,所以反射带给我们的好处是不可否认的。
关于类型信息的总结:
RTTI允许通过匿名基类的引用来发现类型信息。面向对象编程语言的目的是让我们凡是可以使用的地方都使用多态机制,只有在必要的时候才使用RTTI。然而使用多态机制的方法调用,要求我们拥有基类定义的控制权,因为在你扩展程序的时候,可能会发现没有包含我们想要的方法。如果基类是别人的类,或者是由别人控制的话,这时候RTTI也是一种解决之道:可以继承一个新类,然后添加你需要的方法。在代码的其他地方,可以检查你自己特定的类型,并调用你自己的方法。这样做不会破坏多态性和程序的扩展能力,因为这样添加的新类不用在程序中搜索switch语句。但如果程序主体中添加需要的新特性代码,就必须使用RTTI来检查你特定的类型。如果只是为了某个特定类的利益,而将某个特性放在基类里,这就意味着从那个基类派生出的所有子类都带有这个特性,对于其它子类来说都已毫无意义的特性。这会使接口更不清晰,因为我们必须覆盖由基类继承而来的所有抽象方法。例如,考虑表示一种乐器Instrument的类层次结构。假设我们想清洁管乐器中乐器残留的口水,一种办法就是在基类Instrument中放入clearSpitValve()方法。但是这样会造成混淆,因为它意味着打击乐器Percussion、弦乐器Stringed也需要清理口水。在这个例子中,RTTI可以提供一种更合理的解决方案。可以将clearSpitValve()置于特定的类中,在例子中是Wind(管乐器)。同时,你可能会发现有一个更恰当的解决方案,就是将prepareInstrument()置于基类中,但是第一次遇见这个问题可能找不到这个解决方案,而以为只能使用RTTI。
最后一点,RTTI有时能解决效率问题。你可以挑出实现多态时极端缺乏效率的类,然后用RTTI为其编写一段特别的代码来提高效率。当然,不要过早的担心效率问题,这是一个诱人的陷阱,最好首先让程序运行起来,然后再去考虑效率问题。
我们通过上面的例子已经了解反射允许更加动态的编程风格,对有些人来说,反射的动态特征是一种烦恼。但是我认为反射是将Java与其它语言进行区分的重要工具之一。
文章参考:Thinking in Java 第四版