终于要进入面向对象的世界了,虽然C++也是面向对象,但是它的面向对象程度并不高,因为考虑到要兼容C语言的移植性,它依然保留了非常多的C语言的东西,像是万恶的指针,就依然还在那里肆虐着入门者的脑细胞!JAVA之所以说是"C++--",其中有很大程度上是因为它抛弃了指针。
正式进入话题,首先,JAVA继承了之前String对象是不可变的认识,虽然我们表面上看好像是在改变原有的String,像是赋值操作,但实际上是在创建一个全新的String对象,而且就算我们将String对象作为参数传递,也并没有改变原有的String对象,但也并非所有的操作都会产生新的String对象,只有在String的内容发生改变的时候才会,如果没有改变,则只是返回原先的引用,这样是为了减少不必要的开销。
这到底是怎么做到的呢?
首先要从JAVA的引用开始说起。
在JAVA的世界中,操纵内存中元素的方式并不是指针,而是引用。在C++或者其他语言中,我们必须注意我们要处理的数据类型,要在直接操纵元素还是用某种基于特殊语法的间接表示像是指针来操纵对象这两个抉择中进行艰难的选择,很多时候我们都无法确保我们的选择是正确的或者是错误的。但是,这一切在JAVA中完全变得简单了,因为一切都被视为对象,我们可以采用单一固定的语法来操纵这些对象,也就是所谓的引用(reference)。
JAVA的引用非常接近C++的引用,但是这两者还是存在重大的差异。对于C++中的引用,我们可以改变所指向的内容,但是不可以改变它的指向性,但是JAVA可以。C++中对于引用的定义有"对象别名"的说法,它并不像是指针,会分配内存,它就只是一个别名而已,所以我们只能建立一个数组的引用,但是不能建立一个引用数组。但是JAVA的引用则更接近指针,并且它更加复杂,因为它要区分基本对象和复合对象,这在一切皆为对象的JAVA中非常重要,但是一般情况下是不需要知道这些的,因为这涉及到JAVA的底层实现。C++的引用机制为运算符重载提供了非常大的支持,因为C++中重载运算符是不能对指针进行单独的操作的,因为系统已经定义了指针的运算,并且为防止发生错误,语法上不支持修改这些运算,否则原本就难以使用的指针简直就是恶梦了!如果不能使用指针,在没有引用的情况下,我们就只能直接使用该对象,但这样会导致可怕的效率问题,因为按值传参是需要复制对象的,如果是容器这种对象,那简直就是无法想象啊!在按值传递和按址传递都不行的情况下,C++使用了第三种传递方式:按引用传递,因为引用只是别名,所以不产生对象的副本。因为我们使用引用的时候,通常都是不想修改它原本指向的内容,所以最好就是声明该引用为const,这个现象在JAVA中称为别名现象,并且更加严重,因为我们可以将一个对象的引用赋值给同类型的引用,但其中一个引用修改了对象后,这两个引用都会指向被修改后的对象!这个问题的解决非常复杂,尤其是在方法的传参中,因为我们传进来的也是对象的引用,所以这就会导致我们在这个方法中对这个对象所做的更改都会反映到指向该对象所有的引用上,这就是一个严重的问题啊!!为了保证我们在方法中使用的是对象副本,我们就必须想办法解决对象拷贝这个问题。在C++中,因为拷贝构造函数的存在,导致这个问题非常简单,但是在JAVA中,由于不支持拷贝构造函数,所以这个问题的解决方法会比较复杂,就是利用克隆机制。我们知道,Object这个根类已经定义了clone()方法,我们所要做的就是实现cloneable接口,然后覆写clone()方法:
class CloneClass implements Cloneable{ public Object clone(){ CloneClass o = null; try{ o = (CloneClass)super.clone(); }catch(CloneNotSupportedException e){ } } }
这样我们只要调用这个clone()方法就行了。
我们拥有一个JAVA的引用,并不一定需要有一个对象与它关联,就像这样:
String s;
我们创建的只是一个引用,并不是对象,此时我们无法向s发送任何消息,因为它并没有与任何事物相关联。基于此,JAVA强烈建议每个程序员应该在创建一个引用的同时进行初始化,就算一开始不知道要初始化为什么值,可以选择数据类型的默认值,像是String,就可以初始化为一个空字符串。
在JAVA中,创建对象只要两种方式:new和反射,所以,创建一个String对象应该是这样:
String s = new String("hello");
但是JAVA毕竟是C++之后的语言,它依然保留了C++的语法糖:
String s = "hello";
JAVA不支持重载运算符,但是它在系统上为String重载了"+"和"+="这两个运算符用于String的合并。在JAVA中,String的合并比起C++来说更加灵活,我们完全可以这样写:
String s = "hello";
String s1 = s + 1;
这样产生的结果就是"helo1"。
为什么能够做到这点呢?因为在JAVA中,所有的对象都有一个基本的方法:toString(),当我们将一个对象的实例和一个String合并的时候,就会调用这个方法,然后将这个方法的返回值(一个String)和该String合并。如果是不了解JAVA的人,会对上面的例子产生疑问:明明1是基本类型,怎么会调用toString()方法呢?要知道,JAVA所谓的"一切皆为对象"可不是乱盖的,系统已经为每种基本数据类型提供了包装类型,像是int,就有一个包装类型Integer,它们的实例就是对象实例。在上面的例子中,会发生int向Integer的隐式转换,也就是我们常说的自动包装。
扯到了这个合并问题,关于效率的问题就产生了:想想如果我们有一个很大的字符串,它需要和其他更大的字符串多次合并,每次合并都会产生一个新的String,这会产生多么大的内存开销啊!!尤其是在循环中,如果可能的话,一个循环就会把所有的内存跑完!!
JAVA的设计者也是充分意识到这个问题,所以他们也提供了解决方法:StringBuilder和StringBuffer。
我们先来看一下最常用的StringBuilder,因为StringBuffer和StringBuilder最大的区别就是StringBuffer多了一个线程安全性。线程安全性在JAVA中非常重要,因为JAVA一开始是以Applet的形似被人们所熟知,像是这样运行在Web上的插件,对于线程安全的要求是很高的,所以JAVA一直以来都在努力加强自己的线程库,像是String之所以是不可变的,也是考虑到线程安全性,因为Web中文本操作非常多,所以String必须要求是不可变的。
StringBuilder和String之间是不能进行强制类型转换的,但是可以这样:
String s = "hello"; StringBuilder sb = new StringBuilder(s); String s1 = sb.toString();
StringBuilder也允许合并,但是它无法使用"+"和"+=",只能通过append()这个方法:
String s1 = "hello"; String s2 = "world"; StringBuilder sb = new StringBuilder(s1).append(s2);
StringBuilder提供的方法都是对字符串内容的操作,像是插入,反转等等,这些方法只要看一下文档就能马上知道怎么用,我们只讲一个平时很少注意到的方法:trimToSize(),它会将StringBuilder对象中的存储空间缩小到和字符串一样的长度,这样是为了减少空间的浪费,即使我们平时根本就没有注意到这个。
StringBuilder和String还有一个区别:StringBuilder的equals()方法并没有被覆盖,它比较的依然是地址,但是String的equals()则是系统覆盖过,可以直接用来比较字符串(实现的原理就是在比较地址的前提下,再比较哈希值)。
StringBuilder,StringBuffer和String这三者之间的速度比较如下:
StringBuilder > StringBuffer > String。
但是我们也没有必要因为StringBuffer的速度比较快就特意使用StringBuffer,事实上,它和StringBuilder的速度优势只有在至少百万级别的数量上才会体现出来。
使用StringBuilder还有一个值得注意的地方,就是StringBuilder的容量问题。在处理较大的数据时,并且可预见一定会超容,我们最好就是确定StringBuilder的最大容量,因为StringBuilder的构造器创建的是一个默认大小(通常是16)的字符串数组,如果超容,就会重新分配内存,闯将一个更大的数组,并将原先的数组复制过来,再丢弃旧的数组。这种操作在已有的容量非常巨大的情况下,就是一个效率上的恶梦啊!!在JAVA中,尤其是使用容器类的时候,这种恶梦是经常困扰我们的,像是之前C++中讨论的那样,变长字符串的实现是要付出效率的牺牲的。
我们之前在C中讲过格式化的问题,那时是使用了sprintf()函数,而JAVA在SE5中终于正式的引入了这个万众期待的功能!
SE5中,String有一个新的静态方法:format(),这个方法的内部实现是利用了新引入的Formatter这个类,参数和C的sprintf()是一样的,但是这个方法返回的是一个String。
JAVA的String为了适应Web的文本操作,开始支持正则表达式,这是一个非常重要的功能,因为正则表达式对于网页内容和数据的处理发挥了非常大的作用,它本身就是一门简洁强大的语言,但这里不会对正则表达式进行细讲,有兴趣的同学自行学习。
JAVA对正则表达式的支持是通过split()这个方法,它的作用就是将字符串从正则表达式匹配的地方切开,像是这样:
String s = "hello world, can you?";
System.out.println(Arrays.toString(s.split("\W+"));
split()的返回值是一个字符串数组,断开的边界由正则表达式决定,在使用正则表达式的时候,记得转义符号的使用。由于返回的是字符串数组,而我们又不想写一个循环来遍历显示,在JAVA中,提供了相应的语法糖:Arrays.toString()。它的实现原理非常简单,因为在JAVA中,数组也是一种对象,所以数组本身也有自己的toString()方法,这个方法实际上是调用数组中每个元素的toString()方法。
如果只是分割字符串,正则表达式发挥的作用已经非常强大了,但正则表达式还特别便于替换文本,而String也提供了相应的方法。
replaceFirst()用于替换掉第一个匹配成功的部分,而replaceAll()则是替换掉所有匹配成功的部分。这两个方法足以应付一般的情况,但如果想要对这些替换字符串执行某些特殊处理,我们必须使用其他更加强大的方法。
如果我们想要在替换的过程中还对字符串进行处理,可以使用Matcher类的appendReplacement(),它允许我们在替换的过程中,操作用来替换的字符串,像是这样:
StringBuffer sbuf = new StringBuffer(); String s = "hello"; Pattern p = Pattern.compile("[h]"); Matcher m = p.matcher(s); while(m.find()){ m.appendReplacement(sbuf, m.group().toUppercase()); }
m.appendTail(sbuf);
上面同时也是正则表达式库中Pattern和Matcher类的简单使用方法,我们在替换的过程中还将它们改为大写,然后我们再用appendTail()这个方法将字符串剩下的部分复制到sbuf中。
当我们使用Matcher处理完一个字符串后,我们还可以通过调用reset()方法来处理新的字符串。
相比起C++来说,JAVA将String作为内置的对象类型,大大方便了程序员的工作,虽然JAVA的使用效率确实是比C++低,但是JAVA的设计者也是在努力消除这方面的不足之处,所以,在文本处理上,我个人是觉得JAVA的String是更加好用,以后出现的C#的String库的接口基本上和JAVA是一样的,就连一些方法也是完全一样的,所以C#这里并没有什么好讲的。
总结一下这次的重温之旅,我们可以发现,编程语言的发展就是在不断解放程序员的双手,让程序员手动编码的可能大大减少,目的就是避免程序员无谓的编码,只要将关注点放在代码逻辑的组织上就行,至于那些具体的实现,调用相应的接口就行。基于这样的思想,面向对象编程的核心更多是放在代码的设计而不是代码的编写上,实际上,代码的设计工作,就像是类图的编写,远比实际的代码编写来得更加重要,真正的项目中,设计和实现应该是对半分的,而且两者是在相互作用中不断调整的。
JAVA带领我们走进面向对象的世界,而C#因为微软的理念就是不用编码也能编程(具体的例子就是ASP.NET以及组件编程),所以封装度更高,虽然这样的坏处就是灵活度不高,但是程序员的效率得到了大大的提升,而且C#也在其他方面补充了这点灵活性,像是扩展方法等。
String因为并不是基本数据类型,而是系统封装好的对象类型,所以它的发展正是面向对象的发展,也只有在面向对象世界中,它发挥的作用才会越来越大。