String
Java中字符串的操作可谓是最常见的操作了,String这个类它封装了有关字符串操作的大部分方法,从构建一个字符串对象到对字符串的各种操作都封装在该类中,本篇我们通过阅读String类的源码来深入理解下这些字符串操作背后的原理。主要内容如下:
- 繁杂的构造器
- 属性状态的常用函数
- 获取内部数值的常用函数
- 比较大小的相关函数
- 局部操作等常用函数
一、繁杂的构造器
在学会操作字符串之前,我们应先了解下构造一个字符串对象的方式有几种。先看第一种构造器:
private final char value[];
public String() {
this.value = "".value;
}
String源码中第一个私有域就是value这个字符数组,该数组被声明为final表示一旦初始化就不能被改变。也就是说一个字符串对象实际上是由一个字符数组组成的,并且该数组一旦被初始化则不能更改。这也很好的解释了String对象的一个特性:不可变性。一经赋值则不能改变。而我们第一种构造器就很简单,该构造器会将当前的string对象赋值为空(非null)。
接下来的几种构造器都很简单,实际上都是操作了value这个数组,但都不是直接操作,因为它不可更改,所以一般都是复制到局部来实现的各种操作。
//1
public String(String original) {
this.value = original.value;
this.hash = original.hash;
}
//2
public String(char value[]) {
this.value = Arrays.copyOf(value, value.length);
}
//3
public String(char value[], int offset, int count) {
if (offset < 0) {
throw new StringIndexOutOfBoundsException(offset);
}
if (count <= 0) {
if (count < 0) {
throw new StringIndexOutOfBoundsException(count);
}
if (offset <= value.length) {
this.value = "".value;
return;
}
}
// Note: offset or count might be near -1>>>1.
if (offset > value.length - count) {
throw new StringIndexOutOfBoundsException(offset + count);
}
this.value = Arrays.copyOfRange(value, offset, offset+count);
}
无论是第一种的传入一个String类型,还是第二种的直接传入char数组的方式,都是转换为为当前将要创建的对象中value数组属性赋值。至于第三种方法,对传入的char数组有要求,它要求从该数组索引位置为offset开始的后count个字符组成新的数组作为参数传入。该方法首先做了几个极端的判断并增设了对应的异常抛出,核心方法是Arrays.copyOfRange这个方法,它才是真正实现字符数组拷贝的方法。
该方法传入三个参数,形参value,起始位置索引,终止位置索引。在该方法中主要做了两件事情,第一,通过起始位置和终止位置得到新数组的长度,第二,调用本地函数完成数组拷贝。
System.arraycopy(original, from, copy, 0,Math.min(original.length - from, newLength));
虽然该方法是本地方法,但是我们大致可以猜出他是如何实现的,无非是通过while或者for循环遍历前者赋值后者。我们看个例子:
public static void main(String[] args){
char[] chs = new char[]{'w','a','l','k','e','r'};
String s = new String(chs,0,3);
System.out.println(s);
}
输出结果:wal
可以看见这是一种[ a,b)形式,也就是说索引包括起始位置,但不包括终止位置,所以上例中只截取了索引为0,1,2并没有包括3,这种形式的截取方式在String的其他函数中也是常见的。
以上介绍的构建String对象的方式中,基本都是属于操作它内部的字符数组来实现的,下面的几种构造器则是通过操作字节数组来实现对字符串对象的构建,当然这些操作会涉及到编码的问题。下面我们看第一个有关字节数组的构造器:
public String(byte bytes[], int offset, int length, String charsetName)
throws UnsupportedEncodingException {
if (charsetName == null)
throw new NullPointerException("charsetName");
checkBounds(bytes, offset, length);
this.value = StringCoding.decode(charsetName, bytes, offset, length);
}
该方法首先保证charsetName不为null,然后调用checkBounds方法判断offset、length是否小于0,以及offset+length是否大于bytes.length。然后调用一个核心的方法用于将字节数组按照指定的编码方式解析成char数组,我们可以看看这个方法:
static char[] decode(String charsetName, byte[] ba, int off, int len)
throws UnsupportedEncodingException
{
StringDecoder sd = deref(decoder);
String csn = (charsetName == null) ? "ISO-8859-1" : charsetName;
if ((sd == null) || !(csn.equals(sd.requestedCharsetName())
|| csn.equals(sd.charsetName()))) {
sd = null;
try {
Charset cs = lookupCharset(csn);
if (cs != null)
sd = new StringDecoder(cs, csn);
} catch (IllegalCharsetNameException x) {}
if (sd == null)
throw new UnsupportedEncodingException(csn);
set(decoder, sd);
}
return sd.decode(ba, off, len);
}
首先通过deref方法获取对本地解码器类的一个引用,接着使用三目表达式获取指定的编码标准,如果未指定编码标准则默认为 ISO-8859-1,然后紧接着的判断主要是:如果未能从本地线程相关类中获取到StringDecoder,或者与指定的编码标准不符,则手动创建一个StringDecoder实例对象。最后调用一个decode方法完成译码的工作。相比于该方法,我们更常用以下这个方法来将一个字节数组转换成char数组。
public String(byte bytes[], String charsetName)
throws UnsupportedEncodingException {
this(bytes, 0, bytes.length, charsetName);
}
只指定一个字节数组和一个编码标准即可,当然内部调用的还是我们上述的那个构造器。当然也可以不指定任何编码标准,那么则会使用默认的编码标准:UTF-8
public String(byte bytes[], int offset, int length) {
checkBounds(bytes, offset, length);
this.value = StringCoding.decode(bytes, offset, length);
}
当然还可以更简洁:
public String(byte bytes[]) {
this(bytes, 0, bytes.length);
}
但是一般用于转换字节数组成字符串的构造器还是使用由字节数组和编码标准组成的两个参数的构造器。
以上为String类中大部分构造器的源代码,有些源码和底层操作系统等方面知识相关联,理解不深,见谅。下面我们看看有关String类的其他一些有关操作。
二、属性状态的常用函数
该分类的几个函数还是相对而言较为简单的,主要有以下几个函数:
//返回字符串的长度
public int length() {
return value.length;
}
//判断字符串是否为空
public boolean isEmpty() {
return value.length == 0;
}
//获取字符串中指定位置的单个字符
public char charAt(int index) {
if ((index < 0) || (index >= value.length)) {
throw new StringIndexOutOfBoundsException(index);
}
return value[index];
}
有关字符串属性的函数大致就这么些,相对而言比较简单,下面看看获取内部数值的常用函数。
三、获取内部数值的常用函数
此分类下的函数主要有两大类,一个是返回的字符数组,一个是返回的字节数组。我们首先看返回字符数组的方法。
public void getChars(int srcBegin, int srcEnd, char dst[], int dstBegin) {
if (srcBegin < 0) {
throw new StringIndexOutOfBoundsException(srcBegin);
}
if (srcEnd > value.length) {
throw new StringIndexOutOfBoundsException(srcEnd);
}
if (srcBegin > srcEnd) {
throw new StringIndexOutOfBoundsException(srcEnd - srcBegin);
}
System.arraycopy(value, srcBegin, dst, dstBegin, srcEnd - srcBegin);
}
该函数用于将当前String对象中value字符数组的起始索引位置srcBegin到终止索引位置srcEnd拷贝到目标数组dst中,其中dst数组的起始位置为dstBegin索引处。看个例子:
public static void main(String[] args){
String str = "hello-walker";
char[] chs = new char[6];
str.getChars(0,5,chs,1);
for(int a=0;a<chs.length;a++){
System.out.println(chs[a]);
}
}
结果如下:
我们指定从str 的[0,5)共五个字符组成一个数组,从chs数组索引为1开始,一个个复制到chs里。有关获取获取字符数组的函数就这么一个,下面我们看看获取字节数组的函数。
public byte[] getBytes(String charsetName)
throws UnsupportedEncodingException {
if (charsetName == null) throw new NullPointerException();
return StringCoding.encode(charsetName, value, 0, value.length);
}
这个函数的核心方法,StringCoding.encode和上述的StringCoding.decode很相似,只不过一个提供编码标准是为了解码成字符串对象,而另一个则是提供编码标准为了将字符串编码成字节数组。有关getBytes还有一些重载,但这些重载基本每个都会调用我们上述列出的这个方法,只是他们省略了一些参数(使用他们的默认值)。
四、判等函数
在我们日常的项目中可能经常会遇到equls这个函数,那么这个函数是否又是和符号 == 具有相同的功能呢?下面我们看看判等函数:
public boolean equals(Object anObject) {
if (this == anObject) {
return true;
}
if (anObject instanceof String) {
String anotherString = (String)anObject;
int n = value.length;
if (n == anotherString.value.length) {
char v1[] = value;
char v2[] = anotherString.value;
int i = 0;
while (n-- != 0) {
if (v1[i] != v2[i])
return false;
i++;
}
return true;
}
}
return false;
}
我们看到该方法中,第一个判断就使用了符号 == ,实际上等于符号判断的是:两个对象是否指向同一内存空间地址(当然如果他们是指向同一内存的,他们内部封装的数值自然也是相等的)。 从上述代码中我们可以看出,这个equals方法,首先判断两个对象是否指向同一内存位置,如果是则返回true,如果不是才判断他们内部封装的数组是否是相等的。
public boolean equalsIgnoreCase(String anotherString) {
return (this == anotherString) ? true
: (anotherString != null)
&& (anotherString.value.length == value.length)
&& regionMatches(true, 0, anotherString, 0, value.length);
}
该方法是忽略大小写的判等方法,核心方法是regionMatches:
public boolean regionMatches(boolean ignoreCase, int toffset,
String other, int ooffset, int len) {
char ta[] = value;
int to = toffset;
char pa[] = other.value;
int po = ooffset;
if ((ooffset < 0) || (toffset < 0)
|| (toffset > (long)value.length - len)
|| (ooffset > (long)other.value.length - len)) {
return false;
}
while (len-- > 0) {
char c1 = ta[to++];
char c2 = pa[po++];
if (c1 == c2) {
continue;
}
if (ignoreCase) {
char u1 = Character.toUpperCase(c1);
char u2 = Character.toUpperCase(c2);
if (u1 == u2) {
continue;
}
if (Character.toLowerCase(u1) == Character.toLowerCase(u2)) {
continue;
}
}
return false;
}
return true;
}
首先是检错判断,简单判断下传入的参数是否小于0等,然后通过不断读取两个字符数组的字符比较是否相等,如果相等则直接跳过余下代码进入下次循环,否则分别将这两个字符转换为小写和大写两种形式进行比较,如果相等,依然返回true。equals方法只能判断两者是否相等,但是对于谁大谁小则无能为力。 下面我们看看compare相关方法,它可以表两者大小。
public int compareTo(String anotherString) {
int len1 = value.length;
int len2 = anotherString.value.length;
int lim = Math.min(len1, len2);
char v1[] = value;
char v2[] = anotherString.value;
int k = 0;
while (k < lim) {
char c1 = v1[k];
char c2 = v2[k];
if (c1 != c2) {
return c1 - c2;
}
k++;
}
return len1 - len2;
}
该方法将根据字典顺序,判断出两者大小,代码比较简单,不再赘述。忽略大小写的按字典顺序排类似,主要涉及以下方法:
public int compareToIgnoreCase(String str) {
return CASE_INSENSITIVE_ORDER.compare(this, str);
}
这里的compare方法是CASE_INSENSITIVE_ORDER类的一个内部类。
看看我们日常经常使用的这些方法的内部是怎么实现的。第一个函数:
public boolean startsWith(String prefix, int toffset) {
char ta[] = value;
int to = toffset;
char pa[] = prefix.value;
int po = 0;
int pc = prefix.value.length;
// Note: toffset might be near -1>>>1.
if ((toffset < 0) || (toffset > value.length - pc)) {
return false;
}
while (--pc >= 0) {
if (ta[to++] != pa[po++]) {
return false;
}
}
return true;
}
该方法用于判断是否当前的字符串对象是以指定的子串开头。prefix参数指定了这个字串,toffset参数指定了要从原字符串的哪里开始查找。先看个例子:
public static void main(String[] args){
String str = "hello-walker";
System.out.println(str.startsWith("wa", 0));
System.out.println(str.startsWith("wa",6));
}
结果如下:
源代码相对而言也是比较容易理解的,首先是做了个简单的判断,如果toffset小于0或者toffset和prefix的长度超过了原字符串的长度,直接返回false。接着通过了一个while循环从原字符串的toffset位置和prefix的0位置开始,一个字符一个字符的比较,一旦发现有两者在某个位置的字符值是不等的,返回false,否则在循环结束时返回true。该方法还有一个重载,该重载默认toffset为0,即从原字符串的开头开始搜索。
endWith这个方法其实内部调用的还是上述介绍的startWith方法。
public boolean endsWith(String suffix) {
return startsWith(suffix, value.length - suffix.value.length);
}
我们看到该方法内部调用的startsWith方法,第二个参数传入的是value.length - suffix.value.length,该参数将会导致程序跳过前面一部分的字符,直接跳到还剩下suffix.value.length的字符的位置处。
下面我们看看hashCode在String类中的的实现:
public int hashCode() {
int h = hash;
if (h == 0 && value.length > 0) {
char val[] = value;
for (int i = 0; i < value.length; i++) {
h = 31 * h + val[i];
}
hash = h;
}
return h;
}
虽然知道是这么实现的,但是我不知道为什么这么做。只知道它每次都乘31然后加上当前字符的Unicode编号。下面看一个重要的方法:
public int indexOf(int ch, int fromIndex) {
final int max = value.length;
if (fromIndex < 0) {
fromIndex = 0;
} else if (fromIndex >= max) {
return -1;
}
if (ch < Character.MIN_SUPPLEMENTARY_CODE_POINT) {
final char[] value = this.value;
for (int i = fromIndex; i < max; i++) {
if (value[i] == ch) {
return i;
}
}
return -1;
} else {
return indexOfSupplementary(ch, fromIndex);
}
}
indexOf方法用于返回某个字符首次出现的位置,当然对应的还有lastIndexOf,我们一点点看。上述的方法,两个参数,第一个参数的值表示需要查找的指定字符(我们知道字符和int型是可以无条件互转的,所以这里用int接收),后面的代码主要分为两部分,一部分是大部分情况,另一部分则是专门用于处理增补字集情况,该情况我们暂时不去研究。第一部分的代码就比较简单了,遍历整个字符串对象,如果找到指定字符,则返回当前位置,否则返回-1。当然该方法也有一些重载,但本质都是调用了上述介绍的方法。
lastIndexOf方法类似,只不过他是从后往前查找,此处不再赘述。
下面看一个截取子串的方法:
public String substring(int beginIndex) {
if (beginIndex < 0) {
throw new StringIndexOutOfBoundsException(beginIndex);
}
int subLen = value.length - beginIndex;
if (subLen < 0) {
throw new StringIndexOutOfBoundsException(subLen);
}
return (beginIndex == 0) ? this : new String(value, beginIndex, subLen);
}
该方法的前面两个判断主要用于处理一些极端情况,最后一条语句是该方法的核心。如果beginIndex 为0表示截取整个字符串则直接返回当前字符串对象,否则重新构造一个字符串对象。当然该方法自然是有重载的,
public String substring(int beginIndex, int endIndex) {
if (beginIndex < 0) {
throw new StringIndexOutOfBoundsException(beginIndex);
}
if (endIndex > value.length) {
throw new StringIndexOutOfBoundsException(endIndex);
}
int subLen = endIndex - beginIndex;
if (subLen < 0) {
throw new StringIndexOutOfBoundsException(subLen);
}
return ((beginIndex == 0) && (endIndex == value.length)) ? this
: new String(value, beginIndex, subLen);
}
从该重载的两个参数可以看出来,之前只提供一个beginIndex则默认从开始索引处全部截取余下字符。而此处指定endIndex则选择性的截取从beginIndex到endIndex之间的子串作为结果返回。具体的实现也是类似,只是多了一些判断。
下面介绍的方法可以连接两个不同的字符串。
public String concat(String str) {
int otherLen = str.length();
if (otherLen == 0) {
return this;
}
int len = value.length;
char buf[] = Arrays.copyOf(value, len + otherLen);
str.getChars(buf, len);
return new String(buf, true);
}
该方法具有一个参数,该参数的值是一个字符串对象,用于连接在当前字符串对象的后面。前三行很简单,就是判断连接字符串str是否为空,如果是则直接返回当前字符串对象,我们看到很多的方法源码都是会把核心方法放在最后面,前面是一堆判断,这也是一种效率的体现,就是说如果不满足调用该方法的条件则直接在前面被pass了,而不用调用复杂耗时的核心方法。Arrays.copyOf 方法用于创建一个能够容纳上述两个字符串的更大的数组,然后将原字符串复制到进去,后面留给str的位置为空。接着调用getChars方法从偏移量为len的索引位置开始将str中字符拷贝到buf中,最后构建字符串对象返回。
下面看一个更为实用的方法:
public String replace(char oldChar, char newChar) {
if (oldChar != newChar) {
int len = value.length;
int i = -1;
char[] val = value; /* avoid getfield opcode */
while (++i < len) {
if (val[i] == oldChar) {
break;
}
}
if (i < len) {
char buf[] = new char[len];
for (int j = 0; j < i; j++) {
buf[j] = val[j];
}
while (i < len) {
char c = val[i];
buf[i] = (c == oldChar) ? newChar : c;
i++;
}
return new String(buf, true);
}
}
return this;
}
该方法用于替换字符串对象中指定的某个字符,当然它会替换掉所有的oldchar。该方法首先判断oldchar(需要被替换的字符)是否和newchar(替换它的字符)相等,如果相等则不用做任何操作,直接返回当前字符串对象,否则,通过while循环找到第一个oldchar,然后重新构建了一个char数组,该数组和value这个数组长度一样,接着将第一个oldchar位置之前的所有字符复制到新数组中,然后while循环一边遍历value数组查找oldchar并替换为newchar,一边将newchar添加到新数组中,最后返回新数组构造的String 对象。
上述的该方法只能替换指定的一个字符,但是不能替换某个子串。下面的几个方法都是用于替换某个子串。
@1替换第一个子串
public String replaceFirst(String regex, String replacement) {
return Pattern.compile(regex).matcher(this).replaceFirst(replacement);
}
@2替换每一个符合规则的子串
public String replaceAll(String regex, String replacement) {
return Pattern.compile(regex).matcher(this).replaceAll(replacement);
}
@3
public String replace(CharSequence target, CharSequence replacement) {
return Pattern.compile(target.toString(), Pattern.LITERAL).matcher(
this).replaceAll(Matcher.quoteReplacement(replacement.toString()));
}
上述的第一个方法是相对较为好理解,第二个和第三个方法都是替换所有指定的子串,他们的区别在于,replaceAll方法是基于正则表达式的,replace则只针对char串的替换。例如:
public static void main(String[] args){
String str = "aaabssddaa\\\\";
System.out.println(str);
System.out.println(str.replace("\\\\", "x"));
System.out.println(str.replaceAll("\\\\", "x"));
}
输出结果:
我们知道在Java中 表示转义字符,也就是上述的str中 \\ 将被转义成两个 \ ,而在正则表达式中该符号也是转义字符,所以我们 replaceAll 方法中的第一个参数的实际值为:,被转义了两次,所以针对str中的 \的替换,replaceAll 输出两个x,而在replace方法中,四个\被Java转义了一次为两个,所以replace输出一个x。它两区别就是一个是基于正则表达式的,一个则只针对char子串。
下面看一个分割字符串的函数split,由于代码比较多,此处就不贴出来了,我大致介绍下实现原理。该方法的参数依然是依赖正则表达式的,其内部定义了一个ArrayList,定义一个用于匹配字符串的Matcher对象,然后while循环去find原字符串对象,如果找到则直接subSequence前面的所有字符集合,并添加到ArrayList中,然后起始位置从0跳到当前位置之后继续搜索,最后ArrayList对象的toArray方法,返回String类型数组。
下面看一个join方法:
public static String join(CharSequence delimiter, CharSequence... elements) {
Objects.requireNonNull(delimiter);
Objects.requireNonNull(elements);
// Number of elements not likely worth Arrays.stream overhead.
StringJoiner joiner = new StringJoiner(delimiter);
for (CharSequence cs: elements) {
joiner.add(cs);
}
return joiner.toString();
}
首先,该方法是静态方法。然后该方法中涉及到一个类StringJoiner ,它有一个构造方法:
public StringJoiner(CharSequence delimiter,
CharSequence prefix,
CharSequence suffix) {
Objects.requireNonNull(prefix, "The prefix must not be null");
Objects.requireNonNull(delimiter, "The delimiter must not be null");
Objects.requireNonNull(suffix, "The suffix must not be null");
// make defensive copies of arguments
this.prefix = prefix.toString();
this.delimiter = delimiter.toString();
this.suffix = suffix.toString();
this.emptyValue = this.prefix + this.suffix;
}
该构造函数为该类的一些字段赋值,至于这些字段时干什么的,等再次遇到的时候介绍,此处只需了解下他们的存在。此处调用该构造函数并传入delimiter分割符,然后调用了该类对象的add方法,
public StringJoiner add(CharSequence newElement) {
prepareBuilder().append(newElement);
return this;
}
private StringBuilder prepareBuilder() {
//此处value为一个StringBilder实例,是StringJoiner的一个成员
if (value != null) {
value.append(delimiter);
} else {
value = new StringBuilder().append(prefix);
}
return value;
}
第一次add会走else部分,新建一个StringBuilder对象并添加prefix元素(此处在调用构造器的时候为其赋值为空)赋值给我们的成员变量,回到add方法添加该元素到StringBuilder中,第二次到prepareBuilder方法中只会向StringBuilder实例中添加delimiter分割符,然后出来add方法中又将第二个元素添加到其中。这样就完成了为这些元素连接一个分隔符,并放入到StringBuilder实例中,最后tostring返回。看个例子:
public static void main(String[] args){
String[] strs = new String[]{"hello","walker","yam","cyy","huaaa"};
System.out.println(String.join("-",strs));
}
输出结果:hello-walker-yam-cyy-huaaa
最后还有两个方法,比较简单不再赘述其原理实现。
//返回内部的字符数组,之所以不直接返回value是为了封装的严密性
public char[] toCharArray() {
char result[] = new char[value.length];
System.arraycopy(value, 0, result, 0, value.length);
return result;
}
//去除头尾部的空格
public String trim() {
int len = value.length;
int st = 0;
char[] val = value; /* avoid getfield opcode */
while ((st < len) && (val[st] <= ' ')) {
st++;
}
while ((st < len) && (val[len - 1] <= ' ')) {
len--;
}
return ((st > 0) || (len < value.length)) ? substring(st, len) : this;
}