/**
*该类为一个用户的服务接口,这个版本是高效代码版本
**/
public class UserService
{
public int computePayroll(String[] username,String[] password)
{
// TODO:断言注释部分,为Debug用,位置:UserService.computePayroll
//这个是我自己写代码的常用写法assert:username.length == password.length;
int size = username.length;
int totalPayroll = 0;
UserInfo user = new UserInfo();//在迭代外创建对象
for( int i = 0; i < size; i++ )
{
user.setUserName(username[i]);
user.setPassword(password[i]);
totalPayroll += user.getSalary();
}
return totalPayroll;
}
}
用了以上的代码版本过后,你的代码运行比起原始版本要快大概4到5倍左右,但是这样有可能会引入一个误区,典型的使用就是对于集合Vector或者ArrayList的时候,使用这样技术是不现实的。在Java里面,集合保存的是对象引用而不是对象本身,而我们在使用以上高效代码版本的过程里并不会创建对象,这样就使得对象的拷贝不存在,在对象添加到集合里面称为集合元素的时候需要的是一个对象拷贝,否则会使得集合里面所有元素的引用指向一个对象。这种解决最好的办法就是创建对象的拷贝或者直接对对象进行clone操作,这样就可以复用该对象了,所以这种情况使用高效代码版本反而是不合适的做法。
iv.对象的销毁
——◆编程心得[5]:消除过期的对象引用——
我们在C++语言中可以知道,如果要消除内存分配,比如消除某个指针分配的内存要使用关键字delete操作,也就是需要自己手工管理内存。而Java具有垃圾回收功能,它会自动回收过期的对象引用以及不使用的内存,当我们用完了对象过后,系统会自动回收该对象使用期间的分配的内存。但是有一点需要说明的是:我们一旦听说系统能够自动回收内存,所以自己在编程的时候往往觉得没有必要再考虑内存管理的事情了,但实际上这是一个很不好的习惯。这里提供一个完整的例子来说明这中情况:
public class CustomStack
{
private Object[] elements;
private int size = 0;
public CustomStack(int size)
{
this.elements = new Object[size];
}
//……
public Object pop()
{
if ( size == 0 )
throw new EmptyStackException();
return elements[--size];
}
}
这段代码本质上讲没有太大的错误,而且不论怎么测试,它的运行都是正常的,但是却存在一个潜在的“内存泄漏问题”,严格点讲,随着垃圾回收活动的增加,不断占用了使用的内存,程序的性能会逐渐体现出上边这段代码的问题。而且这个潜在的问题有可能还会引起OutOfMemoryError的错误,只是这种问题是潜在的,有可能对于普通应用失败的几率很小很小。
【*:这里提及一个额外的心得,我们开发程序的最初,都不可能拥有庞大的程序数据量,但是我们最好在最初设计系统的时候就考虑到系统在遇到大量数据以及复杂业务逻辑的时候的效率以及其灵活性,虽然开始感觉可能有点复杂或者繁琐,但是这样的设计会使得后期的很多开发变得异常轻松。】
而上边这句话的错误在于:存在过期引用没有进行消除工作,在pop()方法内部我们返回的时候是直接返回的这个堆栈之前的引用,所以就存在了原来的引用指向的对象实际上是没有被清空的。可以看看修改过的版本来对比【仅提供pop函数代码】:
public Object pop()
{
if ( size == 0 )
throw new EmptyStackException();
Object obj = elements[--size];
elements[size] = null ;
return obj;
}
上边代码有一句elements[size] = null,其实道理很简单,该方法的调用目的是返回Stack的移除对象,原始版本里面,返回是对的,但是并没有从Stack中移除,而在返回过后,有一个引用并没有设置为null,使得该对象被保留下来了,这样垃圾回收器在回收的时候不会注意到该对象,所以该对象不会被清除,反复多次调用过后,就使得Stack的操作保留了很多无用的对象下来,这样程序执行时间长了就会OutOfMemoryError。但是有一点就是不能在开发过程过于极端,不能每次遇到这样的问题的时候都去考虑设置为null的情况,本小节的目的是:消除过期的对象引用,这种做法也是尽可能消除,不是所有的内容都依靠程序手动消除,在一定情况下可以依赖Java本身的垃圾回收,也是比较标准的做法。
2)对象属性设置
i.函数参数:
[1]了解Java里面的形参和实参:我们在Java学习中会经常遇到很多文章提及到函数的形参和实参,甚至有时候我们在阅读国外的一些E文的时候被一些专业的术语难住,其实可以这样理解——形参为函数定义中的参数,而实参为函数调用过程中传入函数的实际参数。二者的区别如下:
●形参变量只有在被调用的时候才会分配内存单元,调用结束时,JVM会立即释放该内存单元。因此,形参只在函数内部有效,如果函数调用结束返回主函数后则不能再使用形参变量
●实参可以是常量、变量、表达式、函数,不论实参是哪种类型,进行函数调用的时候,它们都必须具有确定的值,以便把这个值传给形参。因此实参在传入之前应该传入一个初始化过的值
●两种参数在数量、类型、顺序上严格一致,否则会类型不匹配
●在Java的函数调用过程中,数据的值传输应该是单向的,也就是说,在函数调用过程,只能把实参的值传给形参,不能将形参的值回传,这样使得即使形参值变了,也不会影响实参的值
【*:这里结合上边讲到的Java里面的函数按值传递以及按引用传递,最初调用的时,如果传入的是值类型或者对象的引用,这里实际上传进来的是拷贝,这里的拷贝指代的就是将实参的数据传给形参。针对基本类型而言,形参中的值不论怎么修改,都不会改掉实参中的实际数据,所以我们调用函数的时候,最终返回了主函数原来传入的变量里面的值没有改变,因为真正改变的是它传入的拷贝,也就是它传入的实参的目标变量形参。针对引用类型,它的拷贝在此处是一个“浅拷贝模式”,也就是说形参和实参两个引用一个是传入的原始引用,一个是该引用的拷贝,它们在函数执行的过程是指向了同一个对象,做个简单的测试:如果在函数里面我们最初第一行就用new操作符,那么形参和实参引用就会指向不同的对象,而这个时候不论对象怎么变化传入的实参引用指向的对象是不会发生任何变化的。】
看一段简单的代码:
/**
*理解形参和实参的具体传递
**/
public class ParamTester{
public static void main(String args[])
{
int a = 10;
int b = 12;
int c = add(a,b);
System.out.println("c = " + c + " a = " + a + " b = " + b);
StringBuilder str = new StringBuilder("TestParam" );
int d = change(str);
System.out.println("str = " + str + " d = " + d);
}
public static int add(int a,int b)
{
int c = a + b;
a = 13;
b = 14;
return c;
}
public static int change(StringBuilder c)
{
int strLength = c.length();
c = new StringBuilder("Hello Tester");
return strLength;
}
}
上边代码的输出为:
c = 22 a = 10 b = 12
str = TestParam d = 9
关于上边代码传值的流程可以看到:
add方法传入的形参是基本数据类型,虽然改变了a和b,按照基本数据的存储模型而言,这里main代码块里面的变量a和add方法块里面的变量a是存在不同的内存栈上的,实际上main代码块里面的a在这个程序里面就是实参,而add方法块里面的变量a就是形参,因为它们存在不同的内存块里面,所以即使add块里面的a和b都调用了赋值语句,当add方法调用结束,函数返回来了过后,a和b的值还是未被修改过。
而change方法与add方法有本质上的区别,是因为change方法传入的不是原始变量,而是一个StringBuilder类的引用,Java里面所有类传递的都是引用的拷贝,但是需要注意的是下边这句话:
c = new StringBuilder("Hello Tester")
在这句话执行之前,形参引用c和实参引用都是指向同一个对象的,即最开始在main里面定义的内容为“TestParam”的对象,而在change里面,c却因为上边那句代码指向了另外的一个对象,这个对象就是新对象:内容为“Hello Tester”的对象,所以实际上不论c如何改变都不会影响到str引用,这里就是上边提到的,即使形参改变了,实参也不会发生变化。不过这里需要深入理解的是什么样的方式为形参发生了改变?总结为下:
[1]如果形参为原始变量,普通的赋值语句就会引起形参的改变
[2]如果形参为引用变量即对象引用,一定要该引用指向新的对象才能算是形参改变,一般情况为使用new操作符,如果引用指向对象里面的对象内容发生了改变,这种情况下不算形参的改变,而这种特殊情况返回过后的对象内容会引起实参引用对象的变化,而这样的情况是定义为对象改变,而不是形参引用改变,而针对这种改变,在Java里面一般是调用对象本身的方法改变了Java对象的某些属性。
[3]比较特殊的一种情况是Java中的String类,这里只需要记住:String类对于规则[2]不成立,我会在String章节里面专程讲解String类的特性,这也是Java里面String定义为“不变字符串”的主要原因
——◆编程心得[6]:检测函数参数有效性——
在每个函数调用的时候,我们往往对输入的参数是有条件限制的,比如说传入的参数不可为null,传入的参数不能小于0,或者说传入的数组长度不能小于0等各种条件限制,都是很常见的。从逻辑上讲,在没有异常数据从系统录入的情况下,条件限制的存在意义不大,但是规范化了编程过后,在结构相对复杂的系统里面,在函数限制的地方做一定的工作更方便团队合作和开发效率的提高。一般情况下,检测函数的参数的手段有两种,当然这里我只是提到了我自己开发过程常用的两种方法:
[1]针对非公有的方法使用断言:断言对于程序员Debug是一个很好的工具,在1.4版本以上的JVM都可以支持断言,只是javac在编译过程都把断言关闭了,一旦打开断言过后就可以很方便进行Debug操作。这里看一小段代码
private int fun(int a, int b)
{
assert: b != 0:"Value b can not be zero!";
return a / b;
}
上边写了个很简单的整除函数,在除法里面,除数是不能为0的,否则会报错,所以在输入的时候可以限制参数,直接使用断言来判断b的值
[2]针对公有方法使用文档:Javadoc里面有一个@throws标签可以使文档中记录下来违反限制抛出的异常,举个例子:
/**
*一个使用throws进行函数检测的方法
*@param a positive value
*@return BigInteger
*@throws ArithmeticException if a <= 0
**/
public BigInteger getAdd(BigInteger a)
{
if(a.signum() <= 0 )
{
throw new ArithmeticException("Not positive!");
}
//……
}
上边这段程序就是使用javadoc里面@throws标签的例子,使用这个标签过后,最终在生成的文档里面会记录下来如果违反的参数限制抛出来的异常等相关信息。
简单讲,在函数调用之前进行参数的合法值检测是不错的编程方式,但是有一点不能走极端,就是我们必须知道的是,参数的合法性检测是需要系统开销的,所以针对一些无关紧要的信息,以及本身影响不是太大的内容,可以不做检测,仅仅保留一部分文档就好。而断言的使用相对而言比起直接抛异常要高效,是因为javac如果没有打开断言编译的话,JVM会忽略断言语句,就像面对注释一样。不论怎样,我们需要知道的是在编程过程中,考虑检测传入参数的合法性是良好的编程习惯,但是凡是参数都去检测合法性是不懂思考的做法,记得在合理的地方使用合理的方法进行函数参数的有效性检测。
ii.函数原型设计:
——◆编程心得[7]:谨慎设计函数原型——
函数原型的设计,最终目的是在API文档生成的时候使得系统提供的相关API的文档可以规范化,并且在使用过程显得更加顺手,使用Javadoc生成对应的API文档在开发中虽然不会影响系统的运行,但是为系统的维护提供了良好的途径来了解系统,使得系统更加规范化,这里总结几条:
[1]考虑方法名称的选取:方法命名应该参考Java的命名规范来进行设计,我在开发过程无意到了国外一个站点,它们站点针对方法命名中的动词是使用save前缀还是add前缀或者create前缀都是有严格要求的,而且从概念上进行了一定的严格区分,所以方法名的设计最好是根据规范的命名来进行设计。
[2]不能过于提供便利方法:这里所说的便利方法会谈到方法在系统里面真正出现的地方,什么时候我们才会设计一个方法?从经验上讲,当一个方法做了系统里面的同一件事情,而且这个方法的复用几率很高的情况下我们才会设计一个方法也就是函数进行重构。如果某些方法执行流程是一些业务特有的,不会有其他的对象共享,这种情况根据系统实际状况需要思考是否要提供一个便利方法来做这个事情。
[3]避免过长参数列表:一般在开发过程,参数的合理值最好控制在三个,也就是三个参数值的参数列表已经是极限了,如果一个函数的参数列表过多,会使得很多使用者记不住,而且如果提供了过多的同类型参数,会使得用户在使用的时候很不方便,比如:
public void createUser(String username,String password,String email,String address,String postCode,String homepage)
{
//……
}
这样一个方法,难免会使得用户在使用的时候传错值或者说记错了顺序造成开发中不必要的一些麻烦。
一般情况有两种方式可以使得参数列表合理:
●拆分方法,当方法参数列表过长的时候,将方法拆开称为几个方法,而参数的使用采取子集
●创建一个辅助类,用辅助类来保存方法的参数,或者说把这个辅助类当成方法参数的一个实体集合来操作,比如设计一个Class,如下:
public void createUser(UserInfo user)
{
//……
}
上边方法比起前者就简洁很多了
[4]在传引用的过程中,优先选用接口:在函数的参数传入的过程,在接口和类同时存在的情况下,最好的方式是选用接口引用作为参数传入,而不是使用类,这种方式这里提供个简单的例子:
interface A{}
class B implements A{}
public void methodOne(A a) //传递参数为接口引用
{
}
public void methodTwo(B b) //传递参数为类引用
{
}
这里定义了接口A,以及一个类B,methodOne方法传入的参数是接口A的引用,即使在调用过程使用了A a = new B()出现了向上转型,对整个程序而言也增强了该接口引用的灵活性,而methodTwo方法传入的就是类B的引用,使得该应用里面的对象只能是B对象或者B的子类对象,这样就使得其灵活性会差很多。所以在我们设计方法的时候,最好是传入接口引用而不是对象引用,面向接口编程不仅仅是在类设计,在OO的很多细节的地方都要考虑到面向接口的优点,所以这一条的意思就是在函数设计的时候,尽量使用方法methodOne这种方式而不使用methodTwo的方式。
3)垃圾回收
关于finalize方法:
——◆编程心得[8]:避免使用finalize函数——
先看一段摘录:
终结函数的缺点在于不能保证会被及时地执行[JLS, 12.6]。从一个对象变得不可到达开始,到它的终结函数被执行,所花费的这段时间是任意长的。这意味着,注重时间(time-critical)的任务不应该由终结函数来完成。例如,依靠终结函数来关闭已经打开的文件,这是个严重的错误,因为打开文件的描述符是一种很有限的资源。由于JVM会延迟执行终结函数,所以大量的文件会保留在打开状态,当一个程序再不能打开文件的时候,可能会运行失败。
及时地执行终结函数正是垃圾回收算法的一个主要功能,这种算法在不同的JVM实现中会大相径庭。如果程序依赖于终结函数被执行的时间点,那么这个程序的行为在不同的JVM中运行的表现可能就会截然不同。一个程序在你测试用的JVM平台上运行得非常好,而在你最重要顾客的JVM平台上却根本无法运行,这是完全有可能的。
延迟终结过程并不只是一个理论问题。在很少见的情况下,为类提供终结函数,可能会随意地延迟其实例的回收过程。一位同事最近在调试一个长期运行的GUI应用程序的时候,该应用程序莫名其妙地出现OutOfMemoryError错误而死掉。分析表明,该应用程序死掉的时候,其终结函数队列中有数千个图形对象正在等待被终结和回收。遗憾的是,终结函数线程的优先级比该应用程序的其他线程的要低得多,所以,图形对象的终结速度达不到它们进入队列的速度。Java语言规范并不保证哪个线程将会执行终结函数,所以,除了不使用终结函数之外,并没有可移植的办法能够避免这样的问题。
Java语言规范不仅不保证终结函数会被及时地执行,而且根本就不保证它们会被执行。当一个程序终止的时候,某些已经无法访问的对象上的终结函数却根本没有被执行,这是完全有可能的。结论是:不应该依赖终结函数来更新重要的持久状态。例如,依赖终结函数来释放共享资源(比如数据库)上的持久化锁,很容易让整个分布式系统垮掉。
根据这段摘录,可以知道一点的是finalize函数和gc一样,不稳定性很明显,所以我们在实际开发过程中尽量不要自己来定义finalize函数,严格地讲是不要在finalize函数里面释放关键资源。这个函数听起来很好,它是系哦他能够在后期进行垃圾回收的时候执行的操作,但是因为该函数存在的线程优先级过低,它的执行的不确定性很严重,所以我们在实际编程的时候一定要注意该函数的使用,否则有可能给我们开发带来一些不必要的麻烦,就像上边讲到的,还有可能使得系统崩溃掉。
关于该函数这里做几点总结:
[1]finalize()方法用来回收内存以外的系统资源。该方法的调用顺序和用来调用该方法的对象的创建顺序是无关的。也就是说,书写程序时该方法的顺序和方法的实际调用顺序是不相干的,但是这只是finalize()方法的特点。
[2]垃圾收集器跟踪每一个对象,收集那些不可到达的对象,回收其占有的内存空间。但在进行垃圾收集的时候,垃圾收集器会调用finalize()方法,通过让其他对象知道它的存在,而使不可到达的对象再次"复苏"为可到达的对象。既然每个对象只能调用一次finalize()方法,所以每个对象也只可能"复苏"一次。
[3]finalize()方法可以明确地被调用,但它却不能进行垃圾收集操作。
[4]子类的finalize()方法可以明确地调用父类的finalize()方法,作为该子类对象的最后一次适当的操作。但Java编译器却不认为这是一次覆盖操作(overriding),所以也不会对其调用进行检查,不自动执行finalize链。
[5]finalize()方法可以被重载(overload),但只有具备初始的finalize()方法特点的重载方法才可以被垃圾收集器调用。
[6]Java语言允许程序员为任何方法添加finalize()方法,该方法会在垃圾收集器交换回收对象之前被调用。但不要过分依赖该方法对系统资源进行回收和再利用,因为该方法调用后的执行结果是不可预知的。通过以上对垃圾收集器特点的了解,你应该可以明确垃圾收集器的作用,和垃圾收集器判断一块内存空间是否无用的标准。简单地说,当你为一个对象赋值为null并且重新定向了该对象的引用者,此时该对象就符合垃圾收集器的收集标准,而且这里只是符合收集标准,不是强行进行垃圾回收。
而finalize的设计里面,我们也可以这样理解该方法的用途:
第一种用途是,当对象的所有者忘记调用前面段落中建议的显式终止方法时,finalize可以充当"安全网"。虽然这样做并不能保证finalize会被及时地调用,但是在客户端无法通过调用显式的终止方法来正常结束操作的情况下(希望这种情形尽可能地少发生),迟一点释放关键资源总比永远不释放要好。
第二种合理用途与对象的本地对等体有关。本地对等体是一个本地对象,普通对象通过本地方法委托给一个本地对象。因为本地对等体不是一个普通对象,所以垃圾回收器不会知道它,当它的Java对等体被回收的时候,它不会被回收。在本地对等体并不拥有关键资源的前提下,finalize正是执行这项任务最合适的工具。如果本地对等体拥有必须被及时终止的资源,那么该类就应该具有一个显式的终止方法,如前所述。终止方法应该完成所有必要的工作以便释放关键的资源。终止方法可以是本地方法,或者它也可以调用本地方法。
还有一点需要记住:
finalize函数和contractor有一点不同,contractor的构造链是会自动执行的,但是finalize函数链是不会自动执行的,如果子类实现者覆盖了超类的finalize函数,但是忘了手工调用超类的finalize函数(或者有意选择不调用超类的finalize函数),那么超类的finalize函数将永远也不会被调用到。所以根据该函数的特性我们可以知道:
除非是作为安全网,或者是为了终止非关键的本地资源,否则请不要使用finalize。在这些很少见的情况下,既然使用了finalize,就要记住调用super.finalize。
4)继承、接口、抽象类【本小节我会尽量提供概念说明代码】
i.关于继承:
继承是OO中很核心的一个概念,正因为如此,在继承的设计上我们需要小心,继承可以实现代码重用,但是开发过系统的人就明白,继承如同达摩克利斯之剑,用不好就给系统带来了很大的麻烦,但是用好了过后,设计出来的系统还是蛮优雅的。所以需要明白在OO设计中,继承是安全的,但是也是危险的,它要么使得开发出来的软件变得异常优雅,但是使用不好使得整个软件的体系结构变得非常脆弱。
首先需要知道的是继承的一些特性,简单说来,继承的特点在于:
[1]它的诞生使得整个系统的层次结构非常优雅
[2]继承使得子类沿用了父类的大部分属性和操作
而继承本身的优点总结起来:
[1]提高了代码可重用行
[2]子类可以使用父类的部分方法和属性,同样可以扩展父类对应的一部分方法和属性
[3]应用程序的设计变得更加简单,但是整个设计的思考过程会演变得很复杂
在关于继承的思考中,我们可以考虑下边的几条原则:
——◆编程心得[9]:组合优先于继承——
这里先提供两段比较代码:
[1]组合的例子:
/**
*先定义一个类A
**/
class A{
public void method1(){}
public void method2(){}
public void method3(){}
}
/**
*然后定义一个类B,B中包含一个实例变量A,B和A的关系是组合关系
**/
class B{
private A a = null;
public B()
{
a = new A();
}
public void method1(){
a.method1();
}
//......
}
/**
*复合使用过程中的测试代码
**/
public class TestComposition{
public static void main(String args[])
{
B b = new B();
b.method1(); //在此方法的调用中间接调用了A类的方法method1
}
}
[2]继承的例子:
/**
*先定义一个类A
**/
class A{
public void method1(){}
public void method2(){}
public void method3(){}
}
/**
*然后定义一个类B,B类继承于A类,二者是继承关系
**/
class B extends A{
public B(){}
public void method1(){
super.method2();
}
//......
}
/**
*复合使用过程中的测试代码
**/
public class TestInheritance{
public static void main(String args[])
{
B b = new B();
b.method1(); //在此方法的调用中也间接调用了A类的方法method1
b.method2(); //这里调用的方法是B类从A类继承过来的method2方法,同样可以间接调用
}
}
细心的读者会发现上边两段代码对应的利弊,当然这两段代码的业务前提是一致的:
对于组合关系而言,它只是调用了某个类的方法,也就是说调用类和被调用类之间的关系受被调用类暴露接口所限制,上边B和A的组合关系就是个比较直观的例子,在这种关系下边,有两点优势:
[1]B类可以直接调用A类所有暴露出来的方法,但是调用B类的调用者除了通过调用B类的method1方法间接调用A的method1方法外,除非新创建A对象,否则不能通过B去调用A类的其他成员,这样,A类的封装并没有被破坏,需要注意的是,如果真的需要调用A类的method2以及method3去创建A类的话,这种情况不属于破坏了设计A本身的封装,因为这种情况系统已经需要一个A对象了,而A类本身的一些成员,除非通过A类对象的引用进行调用,否则在A对象不存在的情况下,A类的其他成员针对B类的调用者而言,是不可访问的。而且这种情况下,如果A类的某些实现细节有改动的话,B类本身不会受影响,因为这种情况A类面对B和面对外界的调用者本身暴露的接口是类似的,除非A和B在同一个包内,然后A的成员使用了包域,否则外界调用A和B类调用A的域是一致的,如果A因为业务变化改变了实现细节,只要接口未做改动,那么B类不受任何影响。通俗地讲,A和B在这种关系下相对独立,其藕合性是很小的,而A可以当成与B完全无关的一个独立类使用,B只是使用了A类的某部分内容。
[2]从这点上来考虑,继承关系打破了A类的封装,怎么讲呢,第二段程序有条语句为b.method2(),其实这句话意味着A本身暴露出来的接口不仅仅可以通过A本身的引用进行调用,而且可以通过它的子类的引用来进行调用,因为B类继承于A类,所以A中的method2和method3都作为B类父类的成员继承过来了,一旦创建了一个B类的引用,就可以直接通过B类的引用进行调用。其实这里就可以知道,A类的封装性已经被打破了,即使A类的域是包域,一旦有B类的引用,那么A类的某些公有成员就可以访问了,按照这种设计的初衷,包域里面的A类的方法只能提供给同一个包下边的类使用,但是一旦B类公开,就意味着A里面的部分成员因为继承被迫公开了。而且还考虑到一点,一旦A类的某个成员函数发生变化,B是必须要去关心的,因为B继承了A的这个成员,这种情况提高了A和B的耦合性,也就是说:A类的实现细节被迫暴露给子类B了。
反过来看二者的定义:
继承(inheritance),是类 D 的对象可以使用仅对类C的对象有效的方法或者属性的特性,它使得这些方法和属性就好像是由类 D 定义的。这时,C 是 D 的父类,D 是 C 的子类。在继承结构中,父类的内部细节对于子类是可见的。所以我们通常也可以说通过继承的代码复用是一种“白盒式代码复用”。
组合(composition),是通过对现有的对象进行拼装(组合)产生新的、更复杂的功能。因为在对象之间,各自的内部细节是不可见的,所以我们也说这种方式的代码复用是“黑盒式代码复用”。
【*:这里还考虑一点,如果是继承结构,比如B里面仅仅需要使用A的method1,组合是不错的做法,但是如果B在关系上满足is-a的关系,那么就应该考虑使用继承,关于继承的使用下边几点会讲到】
总而言之,继承机制的功能很强大,但是存在一定的问题,因为它违背了封装原则。只有当子类和超类之间确实存在父子关系的时候,使用继承才是最何时的。尽管这样,如果子类和超类在不同的包中,并且超类并不是为了扩展设计的,那么继承将会导致脆弱性,为避免这种情况可以使用组合代替继承。
——◆编程心得[10]:要么专设计继承,要么禁止继承——
上边已经说过了,如果出现OO设计,在业务需求明确的状况下,一般情况下使用组合优先于使用继承。那么如何良好设计一个带继承的类呢?需要说明的是,如要要设计一个带继承的类,最好提供详细的说明文档,因为继承本身容易引起OO中的一些设计上的误区,所以设计继承一定要带上详细的说明文档,当然这是建议,因为以前开发一个系统设计了继承,本身结构比较好,但是没有文档,交接的时候导致很多人都没有办法看懂,所以这也算是经验之谈。
[1]该文档必须精确描述改写了每一个方法带来的影响:
对于继承而言必须体现文档的重要性,简单讲:
该类必须有文档说明自己调用了可改写方法的情况:对于每个公有或受保护的方法或者构造函数,最好说明自己调用了哪些方法以及调用顺序,包括结果怎么处理的等,有时候甚至有必要提供该方法的返回值。当然可以改写的方法一般是指代非final方法,公有的或者受保护的方法。对于设计继承的类而言,用文档说明其内部结构方法与方法之间的依赖关系、调用关系以及影响,是有必要的,因为继承有个很特殊的性质就是一旦这些方法被继承过后,很有可能被人重写或者重载,如果不说明这些内容,就会在重载或者重写过后陷入混乱,这相信是每个系统开发人员都不想遇到的情况。
这里引用一句话:一个好的API的文档更多的是描述一个方法做了什么工作,而不是描述这个方法怎么做的。
[2]一个类必须通过某些形式提供适当的入口,使得所有开发者能够进入继承类的内部工作流程,这样的形式可以是protected域
有时候设计继承的时候,提供出来的方法对整体流程可能没有影响,更多的时候是为了说明其父类内部是如何在操作,提供这样一个类的时候就是我们在设计继承类的时候需要思考的一种因素。如果我们为了继承设计这样一个类,如何决定哪些方法是受保护的或者说相关的域是需要思考的首要问题?这方面的设计没有严格的规则,一方面讲,我们在设计这样的类的时候尽量保证protected域的方法少,原因是每实现这样一个方法,都是该设计针对继承的一种承诺,因为这个protected域的方法可能随时被改写或者暴露出来破坏父类原有的封装;另外一方面,如果提供得太少也不行,因为太少有可能使得我们违背了设计初衷,就为了继承设计的本意。
在关于继承的设计的时候,最为担心的是可改写成员函数的调用问题,这方面下边做几点说明:
●在构造函数中,尽量避免调用可改写方法:主要原因是这种情况会打乱超类和子类在方法调用的实际操作,我们都知道超类的构造子在子类之前进行,如果子类改写版本进行了初始化操作,那么就使得初始化在构造之前进行,这本身就不符合构造对象的逻辑。
●这一点来自书中直接摘录,因为本人没有碰到过:无论是clone方法还是readObject方法,也不能调用一个可改写方法,不论是直接间接,在继承设计里面,慎重考虑对象的Cloneable接口和Serializable接口的对象设计,这也是最头痛的地方
【*:这里做简单的说明,引入了同样的问题,如果是序列化接口被实现了过后,在readObject方法调用的时候,子类会反序列化恢复子类在序列化时候的状态,但是如果在readObject中调用了可改写的方法过后,根据第一条,也就是说,在子类的状态还没有完全恢复的情况下在对子类的成员进行改动操作,这个在继承中存在一定的风险性,很有可能某个子类成员a的反序列化值为2,但是我们希望调用可改写方法过后初始化为3,但是最终结果可能事与愿违,如果在这样的程序里面引入关于线程的编程就会使得整个程序的执行流程更加混乱了,所以这一点附上我的个人理解,这种做法在继承设计里面最好是禁止的。】
整体来讲,为了继承设计一个类的时候,要求对这个类有一定的规范限制,这并不是一件很轻松的事情,如果要明显使用继承,可以使用抽象类,这样使得结构可能更加合理,因为抽象类里面被改写的方法仅仅是抽象类本身实现过的方法,对于普通的类,很多方法既不是final的,也不是为了子类化而存在,这样的类在实现继承的过程是很危险的。
对于这种情况的设计,最好的办法是对于不是为了子类化而定义的类,将某些方法申明为final,使得这样的方法不能继承,或者这个类本身不是为了子类化而设计的话,不需要任何类来继承的类,索性声明为final类。这种做法对很多程序员来说有点不方便,但是进行了这样的操作过后,可以使得我们在一个安全域内编程,不论任何时候都不用担心系统整体的封装性。自用性【自己使用了自己内部的一些属性】在继承设计中是需要花心思去思考的东西,继承设计设计得好可能变得很优雅,设计差了就使得整个系统摇摇欲坠了,这一点我以前在开发一个大型电子商务平台的时候吃过苦,呵呵!
ii.接口、抽象类设计:
——◆编程心得[11]:接口优于抽象类——
Java里面最受争议的角色莫过于接口和抽象类的相关设计,这里再谈接口和抽象类,其主要目的是为了加深印象,前面讲过的基础知识这里不再啰嗦
基于类层次结构的思考:
[1]类的更新
针对本身存在于继承树上的某个类而言,结构更容易更新一个类,因为该类需要做什么操作的时候可以随时实现一个新接口,写了这么多文字,看看下边简单的代码段:
/**
*定义两个抽象类,一个为销售部门行为,一个为开发部门行为
**/
abstract class SellDepartment{
public abstract void sellApple();
}
abstract class DevelopDepartment{
public abstract void developApple();
}
现在需要创建一个类实现sellApple和developApple两种操作,所以当初我们提供了这样一种方案:
abstract class SellDepartment{
public abstract void sellApple();
}
abstract class DevelopDepartment extends SellDepartment{
public abstract void developApple();
}
class Processor extends DevelopDepartment{
//...这里实现两个方法
}
这种改动是可以满足我们的功能,但是有一点,从概念上讲,开发部门和销售部门没有is-a的关系,即使有这样的关系,这种类结构使得我们的实际继承树的类层次被修改了,概念上很混乱,这是一个很不良好的类结构修改设计,当然现在我们都知道怎么修改:
interface SellDepartment{
public abstract void sellApple();
}
interface DevelopDepartment{
public abstract void developApple();
}
class Processor implements SellDepartment,DevelopDepartment{
//...书写相关的两个方法的实现
}
从上边的代码可以看出一点,如果要修改本身已经存在的继承树上的某个类的行为,在我们修改的时候,如果使用抽象类,就会使得很多类的祖先都成为了该抽象类的子类,这样会间接伤害到类的层次结构,强迫这个公共祖先的所有后代都必须添加这样一个某个类可能会拥有的特殊行为,而不管这些真正继承于这个类的子类是否真正需要该方法,都会让其子类去实现,哪怕是个空实现,这样就使得冗余代码量也更多了。所以需要明白的是:在更改类的某些特殊实现的时候,使用接口可以在不破坏类层次结构的情况下完成这种插入功能。从这点意义上讲,带有抽象类就等于使用了继承,回到上边的例子,继承的使用实际上是需要慎重!!的,抽象类虽然除外。但是抽象类仅仅提供了一个很优雅的类层次结构,前提是这些类不能轻易修改行为,否则,一改全改,使得很多子类要再次修改行为的时候需要大动干戈,接口去可以实现简单的横切逻辑的功能,因为仅仅需要实现某个接口就可以了。
[2]混合类型
先解释混合类型,混合类型是一个类除了实现基本类型之外,它还可以实现该混合类型,以表明它提供了一些可供选择的行为。想想,这种做法对接口来讲是轻而易举的,但是抽象类不能定义混合类型,同样的道理因为它不能随时去更新继承树上的某个类,一旦更新就是更新继承链:一个类不可能有一个以上的父类,但是却可以有一个以上的接口,而且在类层次结构中使用抽象类,也没有特殊的位置来放置该混合类型。
[3]类层次结构
接口比抽象类更容易构造非层次结构的类型框架。其实如同上边所讲,抽象类对整个类层次的影响是继承链,而接口对整个层次结构的影响是继承点。
需要说明的是:
接口和抽象类虽然在选择上接口是优先于抽象类的,也不能说抽象类没有任何有点,接下来讲一个抽象类的优势:
抽象类的演化比接口的演化要容易得多——如果你希望在抽象类中增加一个新方法,这个方法提供给所有的子类使用,那么我们使用抽象类可以增加一个具体方法,包含了默认实现,然后该子类集都会拥有这样一个公有方法,而这种演化是接口不能办到的。
总之:接口通常是定义具有多个实现的类型的最佳途径,也是更新继承树上某个类的特性的最佳途径,但是如果我们需要演化操作的时候,应该使用抽象类;记住一点:抽象类影响的是整个继承树上的某个继承链,而接口可以影响某个继承点,这点可以用绘图中的点和面来解释。所以在使用抽象类和接口的时候一定要谨慎设计。
——◆编程心得[12]:减小类和成员的可访问能力——
OO设计里面的核心内容是如何合理地做信息隐藏,Java里面提供了四种访问修饰符:public、protected、default[非关键字]、private,这些访问修饰符针对类和方法的访问都是严格限制的,所以我们在设计良好的模块的时候,重要的是设计这个模块哪些信息是需要对外公布的,哪些信息是需要隐藏的。OO里面我们提倡设计过程隐藏内部的细节实现,针对一些类而言,我们如何封装信息也是需要考虑的细节。比如:
/**
*定义一个用户类,该用户类不能设置身份证号
**/
public class UserInfo
{
private String userName;
private String password;
private String identityCode;
public void setUserName(String userName)
{
this.userName = userName;
}
public void setPassword(String password)
{
this.password = password;
}
public String getUserName()
{
return this.userName;
}
public String getPassword()
{
return this.password;
}
public String getIdentityCode()
{
return this.identityCode;
}
}
上边做了简单的信息封装,因为身份证号码只读不可写。
在信息隐藏过程,我们需要坚持这样一个原则:我们应该尽可能使得每一个类或者成员不能被外界访问,也就是说尽可能地隐藏更多的信息。在OO设计里面我们最好的设计有时候评判的是模块化的程度,也就是我们平时提及到的高内聚和低耦合,高内聚就是说某些模块尽可能完成自己必要的职责,不画蛇添足同样也不能力不从心,这两种方式都是不好的内聚方式;而低耦合就是模块与模块之间的依赖程度的描述,其实可以这样讲,我们在设计系统的时候尽可能使得模块和模块的独立性更强。这里谈谈我个人在设计系统的经验,我把模块和模块之间的依赖分为两种,一种称为静态依赖,一种成为动态依赖。静态依赖就是最初设计的时候,模块对外暴露的方法的一个固定格式,是必要的,也就是说这种依赖刚好可以覆盖业务需求,这种情况下是我们必须保证的系统模块依赖;而动态依赖就是某些模块提供了不应该暴露的接口,虽然我们在系统里面不会用到,但是这种接口有可能为成为系统的隐患保留下来。所以坚持一个原则:
静态依赖以覆盖业务需求的最小范围为主,动态依赖在不排除设置系统扩展性的前提下,越少越好。
——◆编程心得[13]:接口仅仅用于定义类型——
当一个接口在使用的时候,接口是为了定义某个类型而存在的,而此类型引用可以直接引用实现了该接口的类的实例。所以,接口的定义是为了表示该接口可能被客户用来引用某个实例并且调用该实例实现了接口的行为而设置的,任何出于其他目的的接口用法,都是不良好的设计。
这里需要谈谈的是常量接口:
我们都知道,接口里面可以定义接口常量,而且接口本身是不含变量的,有一种接口仅仅包含了final域,每个域都表示一个常量,如果一个类要使用该常量,它只要实现该接口就可以了。比如:
/**
*这里定义了一个常量接口
**/
interface ConstantValue{
static final int TYPE_ONE = 1;
static final int TYPE_TWO = 2;
static final int TYPE_THREE = 3;
}
class ConstantClass implements ConstantValue
{
public void testMethod()
{
System.out.println("Hello Type " + TYPE_ONE);
}
}
以上就是一个常量接口的典型应用,我们平时在开发的时候也会犯这种设计的错误,主要目的确实是因为常量接口太好用了,呵呵,但是:常量接口是一种不良设计!虽然Java类里面本身有些接口是常量接口,但是这种做法不值得效仿。这样做有个很大的弊端:接口本身存在是为了某种OO的设计契约而存在的,这样等于暴露了一些内部的细节到外面,关键是在规范化开发流程里面,一旦某个实现该常量接口的类被修改了,它不再需要这些常量了,那么这些对外的接口就没有特殊意义了,而且对用户本身而言,实现该常量接口的方式来调用常量,是没必要的也没有任何存在价值的。
所以需要知道的一点:接口为了定义类型而存在,而不是为了方便开发而存在,比如说定义常量。
iii.关于instanceof关键字:
这里先提供一点关于instanceof最近我才发现的资料:
import java.util.List;
public class InstanceDemo{
public static void main(String args[])
{
//System.out.println(new InstanceDemo() instanceof String);
System.out.println(new InstanceDemo() instanceof Object);
System.out.println(new InstanceDemo() instanceof List);
}
}
这里被注释的这句话会出现编译错误,而后两句不会出现编译错误,下边是输出:
true
false
很多人可以理解第一句和第二句,但是第三句不能很好的理解,因为第三句可以通过编译,总会和第一句混淆
实际上是这样的:
这个是JVM规范里面定义的,针对JVM本身而言,它当然期望几乎所有的类型检查已经在运行之前就已经做好了,通俗地讲在编译时检查类型而不用JVM自身来进行类型检查。而JVM规范中通常进行该操作的有四个指令:
invokevirtual
invokeinterface
invokestatic
invokespecial
这里将invokeinterface标注为红色,是因为这个指令和另外三个指令是有差别的,这个指令的关键在于:
按照这个逻辑上的四行代码就很好理解了:
new InstanceOfDemo() instanceof String
这两句先看右边,因为String和Exception都是类而并非接口,所以这种情况JVM不会调用invokeinterface的指令,这种情况下JVM会在编译期去检查该对象是否属于某个类型或者该类型的子类型,这种情况下这两句就会编译时报错,因为这里InstanceOfDemo的直接父类是Object
new InstanceOfDemo() instanceof Object
本身是合法的,因为自定义类型是Object的子类,所以这里这种检查是可以完全通过编译的
而这句话:
new InstanceOfDemo() instanceof List
这句话注意的是List本身是一个接口类型,这种情况下JVM就会调用invokeinterface指令,根据JVM规范讲的,这种指令不会在编译的时候去检查某个对象是否实现了该接口,那么这句话能够通过编译是理所当然的了。
——◆编程心得[14]:多态优于instanceof——
同样还是先看一段代码,一段以前写的很烂的设计的概念代码:
interface A{
public int foo();
}
class B implements A{
public int foo(){
return 12;
}
public void printType()
{
System.out.println("B class");
}
}
class C implements A{
public int foo(){
return 16;
}
public void printType()
{
System.out.println("C class");
}
}
public class TestInstance{
public static void printType(A a)
{
if( a instanceof B )
((B)a).printType();
else
((C)a).printType();
}
public static void main(String args[])
{
A a1 = new B();
A a2 = new C();
printType(a1);
printType(a2);
}
}
经过以上代码可以看出,当我们调用printType的时候,需要使用instanceof来判断该引用指向的对象到底是B还是C,这样的设计很有问题:
●这样的设计缺乏性能
●这样的设计不够优雅
●这样的设计同样不能扩充
照理说判断某个类型应该是JVM在运行过程自己做的事情,而不应该让程序员通过反向判断来完成这个操作,这样我们确实能够实现我们需要的功能,但是对比下边的设计,我们会觉得多态更优雅,而且更加优秀:
interface A{
public int foo();
public void printType();
}
class B implements A{
public int foo(){
return 12;
}
public void printType()
{
System.out.println("B class");
}
}
class C implements A{
public int foo(){
return 16;
}
public void printType()
{
System.out.println("C class");
}
}
public class TestInstance{
/*public static void printType(A a)
{
if( a instanceof B )
((B)a).printType();
else
((C)a).printType();
}*/
public static void main(String args[])
{
A a1 = new B();
A a2 = new C();
a1.printType();
a2.printType();
//printType(a1);
//printType(a2);
}
}
被重新设计过的代码结构,就会觉得这样的设计更加优秀。总结下来:instanceof操作符很容易被误用,很多场合我们需要使用多态来代替instanceof,无论何时看见instanceof出现,都得判断是否用多态来改进设计以消除它。这样的设计,会使得设计结构更加优秀。
——◆编程心得[15]:使用instanceof的必要性——
这里需要提供一种特殊情况:面对一个设计不当的class库,用户无法避免使用instanceof操作符,实际上有很多特殊情况使我们不得不使用instanceof操作符,例如当我们必须从一个基础类型转型为一个派生类型,为了避免某些转型错误以及转型异常,最好的办法就是使用instanceof,这种情况下,是有必要选择使用该关键字的。
class A{}
class B extends A{
public double radius()
{
return 2.2;
}
}
class C extends A{
public double radius()
{
return 5.5;
}
}
public class TestInstance
{
public static void main(String args[])
{
A a1 = new B();
//A a2 = (C)a1;
//这是不太良好的写法,这种情况下,最好使用以下写法
if( a1 instanceof C )
A a2 = (C)a1;
}
}
以上这种情况是为了防止运行时异常转型,当然处理这种情况可以总结为两种办法:
[1]instanceof操作符
[2]使用try/catch区段,处理ClassCastException
【*:这里再提一段经历,去年开发某个系统的时候,我们大量使用了依赖注入的方式,虽然当时是使用.NET平台开发的,但是如果从Java的思维来考虑,我总结了一些心得:在我们设计系统的时候,往往会想到针对某些类进行共性抽取,然后提供一个基类,这个类往往是抽象类。然后针对扩展的子类属性,使用某个子类来操作,其实这种做法很冗余,如果子类过多会出现对象积累的各种方法。这种方法会用到instanceof来完成类型的判断操作,这个时候必须知道我们真正引用的对象是什么,才能通过基类来引用子类对象,而且这样的情况使用instanceof不仅仅是为了处理转型异常,更多的时候是为了使得依赖注入的模式变得更加顺利。因为抽取了所有子类的共性过后,不可能再从子类里面抽取对应的属性,所以在引用真正指向的对象的类型判断的时候,必须要使用instanceof这样的关键字。】
总之:能够使用多态代替instanceof的情况,尽量使用多态,不到万不得已的时候,不使用instanceof。
5.总结:
写到这里,基本上关于Java类和对象所有的内容都已经涵盖了,若有遗漏的地方,请Email告知,谢谢!整体结构都是以知识点为主,其主要目的有三点:
第一,为了方便初学者按照一定的体系结构来掌握Java最核心的OO部分的知识,目录是按照OO部分关键知识点写的
第二,为了开发者查询该知识来去除某些疑难杂症,包括提供的代码有些内容都是比较良好的概念说明代码
第三,私人原因,因为有很多开发经验和心得,做一次整理,以方便深入思考,温故而知新
本文参考书籍:《Effective Java》、《Practical Java》
【*:编程心得是结合两本参考书籍里面提及到的点以及个人在程序开发过程遇到过的问题作出的一些说明和整合,每个点里面有大部分思考内容是我个人根据开发过程中遇到的情况进行的整理,里面有些更加深入的内容我做了删减,如果有兴趣的人我推荐几本教材作为深入学习Java的补充教程。关于前面知识结构的点上的有些笔误的地方,请读者来Email告知,慢慢修正,本文仅为草案。而且很多书上没有提及的地方,是因为没有开发经验,开发过程也没有遇到这些情况,凡是讲过的结合书中的点,我都遇到过这样的设计初稿以及在项目开发过程碰到了因为这些点没有掌握遇到的麻烦,所以为经验之谈,如果有人看过其他开发心得能够提供相关资料以及实践经验的结合文档的话,不甚感激。】