有些面试经验都会发现目前市场上大多数公司都喜欢面试字符串处理,诸如字符串倒置,字符串拼接,字符串替换真是层出不穷屡试不爽。之 所以会这样是跟计算机处理的对象有密切关系的。现在的计算机越来越专注于处理更多复杂的外界信息,而外界信息在计算机内存放很大部分是以串的形式。这也正 是本文讨论的意义所在。
String类是一个比较值得学习的类,不仅因为字符串处理的重要性,还在于这个类用到了许多JAVA的高级类和处理,对于学习JAVA非常有帮助。
本文主要从分析JDK中String类入手,着重复习字符串处理的一些操作和技巧,而这些恰恰是程序编写的硬功夫。打下了基础之后便不会再为高级的字符串处理而伤脑筋了。
String类实现了几个接口,分别是Serializable, Comparable, CharSequence。可以看出来,String类能够被序列化,能够比较,那么CharSequence是什么用的呢?顾名思义,它是字符序列的接 口,主要定义的了一个有序的字符序列该有的行为,大名鼎鼎的length(),toString(),charAt()都是它定义的,除此之外还有 subSequence()-求子序列。
首先我们得关注String类中几个重要的实例变量:字符数组value来保存字符串,count来指示该字符串当前的长度,offset指示字符串的起始偏移量,一般都是0,这几个概念很重要,以后会经常用到。
纵观String代码,比较重要的方法可以归结为这么几个:
·
charAt
·
CompareTo
·
Concat
·
startsWith, endsWith
·
equals
·
getBytes
·
indexOf, lastIndexOf
·
replace, replaceAll
·
split
·
toLowerCase, toUpperCase
·
trim
下面对这些比较重要的方法一一分析。
charAt(int index),这个方法返回指定位置的字符。代码如下,非常简单,其实就是操作字符数组
public char charAt(int index) { if ((index < 0) || (index >= count)) { throw new StringIndexOutOfBoundsException(index); } return value[index + offset]; } |
compareTo(),继承了comparable都必须实现的方法,主要是比较两字符串大小,该方法是常考的题目之一,得注意其实现方法。主要逻辑就 是看两字符串起始偏移是否一样,如一样,就一个一个往后比较字符谁大谁小;如果不一样就要分别有指针指示两个字符串当前比较位置。当然如果比较到最后就得 比长度了。
public int compareTo(String anotherString) { int len1 = count; int len2 = anotherString.count; int n = Math.min(len1, len2); char v1[] = value; char v2[] = anotherString.value; int i = offset; int j = anotherString.offset; if (i == j) { //比较两串的起始偏移 int k = i; int lim = n + i;//最多比较最小串长+偏移 while (k < lim) { char c1 = v1[k]; char c2 = v2[k]; if (c1 != c2) {//第一个不等的字符 return c1 - c2; } k++; } } else {//两串偏移不等 while (n-- != 0) {//最多比较最小串长 char c1 = v1[i++];//串v1使用i做下标 char c2 = v2[j++];//串v2使用j做下标 if (c1 != c2) { return c1 - c2; } } } return len1 - len2;//字符之前都相等,但有可能长度不等 } |
concat(),连接两个字符串,新建一个字符串,将两个字符串拷贝进去,这也是常考题之一,注意它有个copy的过程。
public String concat(String str) { int otherLen = str.length(); if (otherLen == 0) { return this; } char buf[] = new char[count + otherLen]; getChars(0, count, buf, 0);//将源串长count个字符copy进buf中 str.getChars(0, otherLen, buf, count);//将目标串长otherLen个字符copy进buf中 return new String(0, count + otherLen, buf);//用buf建一个长count+otherLen的新串 } |
startsWith(),判断字符串是否以指定字串开头,注意条件的判断。
public boolean startsWith(String prefix, int toffset) { char ta[] = value; int to = offset + toffset;//源串原始偏移+相对偏移=源串的当前偏移 char pa[] = prefix.value; int po = prefix.offset; int pc = prefix.count; // Note: toffset might be near -1>>>1. if ((toffset < 0) || (toffset > count - pc)) { return false; } while (--pc >= 0) {//扫描pc个字符 if (ta[to++] != pa[po++]) {//如果不等,匹配不成功 return false; } } return true;//匹配成功 } |
toUpperCase:将字符串转换为大写。首先是对字符串进行扫描,看什么时候转换出错或者什么时候该字符不是大写,换言之就 是找到第一个是小写的地方,这里判断c != upperCaseChar就是做这件事的,当然如果没有出现小写或者出错,我们就不用做任何转换,直接返回就行了。注意这里scan的用法,定义一个 块,break时可以直接break出这个块。其后是对土耳其语进行特殊处理的一段,有兴趣可以仔细研究。将之前的大写全部copy进新的数组后就是开始 转换了,转化其实非常简单,就是直接调用Character的toUpperCaseEx(),但这里有个例外情况,就是转换中字符出错的情况,出错原因 是该字符能转为多个字符,这时就需要调用Character的toUpperCaseCharArray()方法,对于这种情况我们就需要新准备一个返回字符集,这个字符数组的长度是原来的长度加上因出错多出来的长度。
我想,这时要写一个toLowerCase就很简单了,可以参看toLowerCase的源码。
public String toUpperCase(Locale locale) { int len = count; int off = offset; char[] val = value; int firstLower; /* Now check if there are any characters that need changing. */ //scan主要目的是找出第一个小写的位置,之前的就不用做转换 scan: { char upperCaseChar; char c; for (firstLower = 0 ; firstLower < len ; firstLower++) { c = value[off+firstLower]; upperCaseChar = Character.toUpperCaseEx(c);//转换成大写 if (upperCaseChar == Character.CHAR_ERROR || c != upperCaseChar) {//出错或字符是小写 break scan; } } return this;//未出错且字符全是大写,不用转换,直接返回 } //新建一个字符数组,用来存放转换后的字符串 char[] result = new char[len]; /* might grow! */ int resultOffset = 0; /* result grows, so i+resultOffset * is the write location in result */ /* Just copy the first few upperCase characters. */ System.arraycopy(val, off, result, 0, firstLower); if (locale.getLanguage().equals("tr")) { // special loop for Turkey char[] upperCharArray; char upperChar; char ch; for (int i = firstLower; i < len; ++i) { ch = val[off+i]; if (ch == 'i') { result[i+resultOffset] = '/u0130'; // dotted cap i continue; } if (ch == '/u0131') { // dotless i result[i+resultOffset] = 'I'; // cap I continue; } upperChar = Character.toUpperCaseEx(ch); if (upperChar == Character.CHAR_ERROR) { upperCharArray = Character.toUpperCaseCharArray(ch); /* Grow result. */ int mapLen = upperCharArray.length; char[] result2 = new char[result.length + mapLen - 1]; System.arraycopy(result, 0, result2, 0, i + 1 + resultOffset); for (int x=0; x result2[i+resultOffset++] = upperCharArray[x]; } --resultOffset; result = result2; } else { result[i+resultOffset] = upperChar; } } } else { // normal, fast loop char[] upperCharArray; char upperChar; char ch; for (int i = firstLower; i < len; ++i) { ch = val[off+i]; upperChar = Character.toUpperCaseEx(ch); //if主要是将出错字符变成字符数组,因此涉及一系列重新分配字符数组的过程 if (upperChar == Character.CHAR_ERROR) { upperCharArray = Character.toUpperCaseCharArray(ch); /* Grow result. */ int mapLen = upperCharArray.length; char[] result2 = new char[result.length + mapLen - 1]; System.arraycopy(result, 0, result2, 0, i + 1 + resultOffset); for (int x=0; x result2[i+resultOffset++] = upperCharArray[x]; } --resultOffset; result = result2; } else {//正常转换 result[i+resultOffset] = upperChar; } } } return new String(0, result.length, result);//以result字符数组新建一个字符串 } |
equals:这是继承自Object所必须实现的方法。首先验证要比较对象的引用是否就是当前对象,如果是自然是equals的,当然返回true。否 则检验该对象是否是一个字符串实例,只有是才有比较是否相等的意义。如果该对象是一个字符串实例,开始比较它们的count,count不等肯定不会 equals,相等了再分别比较字符串的值。我想这时要写个equalsIgnoreCase应该也不是很难的事了,有思路了吗?
public boolean equals(Object anObject) { if (this == anObject) { return true; } if (anObject instanceof String) { String anotherString = (String)anObject; int n = count; if (n == anotherString.count) {//必须长度相等才可能相等 char v1[] = value; char v2[] = anotherString.value; int i = offset; int j = anotherString.offset; while (n-- != 0) {//一旦不匹配,说明不等 if (v1[i++] != v2[j++]) return false; } return true; } } return false;//不是字符串实例一定不等 } |
trim:就是从头和尾分别用两个指针扫描,只要是白色字符就跳过,然后取中间。
public String trim() { int len = count; int st = 0; int off = offset; /* avoid getfield opcode */ char[] val = value; /* avoid getfield opcode */ while ((st < len) && (val[off + st] <= ' ')) { st++; } while ((st < len) && (val[off + len - 1] <= ' ')) { len--; } //两个变量是否有变化,有则返回子串,否则不做处理 return ((st > 0) || (len < count)) ? substring(st, len) : this; } |
indexOf:查找一个字符串在源串中首次出现的位置,这里着重分析这个带很多参数的indexOf。首先需要对一些边界条件检查。诸如指针大于源串的最大长度都需要被判定。接下来又定义了一个块startSearchForFirstChar。 String没有使用数据结构中学过的KMP算法,不过据说KMP在小长度串匹配时并不是有很高的效率,而String的indexOf目标是做一个通用 的匹配,所以采用了常规的方法,即便如此,思想仍是非常值得学习借鉴的。整个算法流程见图:
其步骤大致可分为:匹配第一个字符,匹配余下字符,完全匹配。首先就是在合适的范围内寻找是否匹配了第一个字符,如果超出这个范围,就认为是没找到,返回 -1;一旦匹配了第一个字符,则用两个指针分别对源和目标串进行余下字符的匹配,如果目标串没完全匹配,说明不是该字符,这里用了一个continue startSearchForFirstChar,就是从头再来,否则两者完全相符就是完全匹配,返回当前匹配的位置。程序虽简单,要想写好却不容易,还需多多研究啊。
static int indexOf(char[] source, int sourceOffset, int sourceCount, char[] target, int targetOffset, int targetCount, int fromIndex) { if (fromIndex >= sourceCount) {//指定起始大于源串长 return (targetCount == 0 ? sourceCount : -1);//如果目标串长为0就返回源串长,否则返回-1 } if (fromIndex < 0) {//如果指定起始小于0,让它等于0 fromIndex = 0; } if (targetCount == 0) {//如果目标串长为0,返回指定起始 return fromIndex; } char first = target[targetOffset]; int i = sourceOffset + fromIndex; int max = sourceOffset + (sourceCount - targetCount); startSearchForFirstChar: while (true) { /* Look for first character. */ //在指定范围内找第一个相匹配的字符 while (i <= max && source[i] != first) { i++; } //超出指定范围,匹配失败,返回-1 if (i > max) { return -1; } /* Found first character, now look at the rest of v2 */ int j = i + 1; int end = j + targetCount - 1; int k = targetOffset + 1; while (j < end) {//匹配后续字符 if (source[j++] != target[k++]) {//不完全匹配 i++; /* Look for str's first char again. */ continue startSearchForFirstChar;//从头再来 } } return i - sourceOffset; /* Found whole string. */ } } |
最后剩下replaceAll和split,这两个比较重要的方法用到了正则表达式相关的类
续……
关于String这个比较重要的类还是有很多值得一提的,最常见的一个问题就是字符串的传递问题了。有如下程序
public class Test{ public static void stringReplace(String text){ text=text.replace("j","l"); } public static void bufferReplace(StringBuffer text){ text=text.append("c"); } public static void main(String args[]){ String textString=new String("java"); StringBuffer textBuffer=new StringBuffer("java"); StringReplace(textString); bufferReplace(textBuffer); System.out.println(textString+textBuffer); } } |
最常问的就是怎么结果是javajavac而不是lavajavac。很多人都把这个问题归为call by value还是call by reference的问题,其实并非如此。
首先,java的所有对象都是建立在heap上的,对java对象的传递规定使用call by reference,所以String和StringBuffer都是call by reference,可以参考《thinking in java》,这是完全没有争议的。因此StringBuffer输出了call by reference的正确值javac;但同时看到String却没有输出lava,因此你可能会误认为String是call by value的,这是一个概念性的错误。String是一个特殊的类,特殊在它用了final关键字修饰,也就是说每个String都是不变的 (immutable)。你可能会问像s = s + "abc";这样的语句String变了。那么,分析下这句话就知道了,虚拟机新分配一段空间,将s这个引用指向的字符串和另一个字符串abc拿出来拼在 一起装在这个空间中,之后将s指向这个空间,所以String实际上没变,注意到第一个String是s的老空间,第二个String是"abc",第三 个字符串是s + "abc",它们三个的地址都不同。
回过头来解决上面这个问题。当String被传进stringReplace()后多了一个引用text指向原引用textString,这时我们对 text进行操作text = text.replace("j","l");当执行完后新分配了一个存储空间放"lava",text指向这个存储空间,但接下来函数执行完返回。可以 想像结果,因为String的特殊性,text并没有操作了原来那个字符串(textString指向的那个),而是在一个新的地址,所以原来那个 textString仍然不变,输出自然也不会变。
这是个非常有趣的问题,那么要想让值确实的改变应该如何呢,答案是将这个新产生的地址传回去,修改上面代码:
public static String stringReplace(String text){ return text.replace('j', 'l'); } //main textString = stringReplace(textString); |