計算機程式的思維邏輯 (29) – 剖析String

swiftma發表於2019-02-25

本系列文章經補充和完善,已修訂整理成書《Java程式設計的邏輯》(馬俊昌著),由機械工業出版社華章分社出版,於2018年1月上市熱銷,讀者好評如潮!各大網店和書店有售,歡迎購買:京東自營連結

計算機程式的思維邏輯 (29) – 剖析String

上節介紹了單個字元的封裝類Character,本節介紹字串類。字串操作大概是計算機程式中最常見的操作了,Java中表示字串的類是String,本節就來詳細介紹String。

字串的基本使用是比較簡單直接的,我們來看下。

基本用法

可以通過常量定義String變數

String name = "老馬說程式設計";
複製程式碼

也可以通過new建立String

String name = new String("老馬說程式設計");
複製程式碼

String可以直接使用+和+=運算子,如:

String name = "老馬";
name+= "說程式設計";
String descritpion = ",探索程式設計本質";
System.out.println(name+descritpion); 
複製程式碼

輸出為:

老馬說程式設計,探索程式設計本質
複製程式碼

String類包括很多方法,以方便操作字串。

判斷字串是否為空

public boolean isEmpty()
複製程式碼

獲取字串長度

public int length()
複製程式碼

取子字串

public String substring(int beginIndex)
public String substring(int beginIndex, int endIndex) 
複製程式碼

在字串中查詢字元或子字串,返回第一個找到的索引位置,沒找到返回-1

public int indexOf(int ch)
public int indexOf(String str)
複製程式碼

從後面查詢字元或子字串,返回從後面數的第一個索引位置,沒找到返回-1

public int lastIndexOf(int ch)
public int lastIndexOf(String str) 
複製程式碼

判斷字串中是否包含指定的字元序列。回顧一下,CharSequence是一個介面,String也實現了CharSequence

public boolean contains(CharSequence s)  
複製程式碼

判斷字串是否以給定子字串開頭

public boolean startsWith(String prefix)
複製程式碼

判斷字串是否以給定子字串結尾

public boolean endsWith(String suffix)
複製程式碼

與其他字串比較,看內容是否相同

public boolean equals(Object anObject)
複製程式碼

忽略大小寫,與其他字串進行比較,看內容是否相同

public boolean equalsIgnoreCase(String anotherString)
複製程式碼

String也實現了Comparable介面,可以比較字串大小

public int compareTo(String anotherString)
複製程式碼

還可以忽略大小寫,進行大小比較

public int compareToIgnoreCase(String str)
複製程式碼

所有字元轉換為大寫字元,返回新字串,原字串不變

public String toUpperCase()
複製程式碼

所有字元轉換為小寫字元,返回新字串,原字串不變

public String toLowerCase()
複製程式碼

字串連線,返回當前字串和引數字串合併後的字串,原字串不變

public String concat(String str)
複製程式碼

字串替換,替換單個字元,返回新字串,原字串不變

public String replace(char oldChar, char newChar)
複製程式碼

字串替換,替換字元序列,返回新字串,原字串不變

public String replace(CharSequence target, CharSequence replacement) 
複製程式碼

刪掉開頭和結尾的空格,返回新字串,原字串不變

public String trim() 
複製程式碼

分隔字串,返回分隔後的子字串陣列,原字串不變

public String[] split(String regex)
複製程式碼

例如,按逗號分隔”hello,world”:

String str = "hello,world";
String[] arr = str.split(",");
複製程式碼

arr[0]為”hello”, arr[1]為”world”。

從呼叫者的角度理解了String的基本用法,下面我們進一步來理解String的內部。

走進String內部

封裝字元陣列

String類內部用一個字元陣列表示字串,例項變數定義為:

private final char value[];
複製程式碼

String有兩個構造方法,可以根據char陣列建立String

public String(char value[])
public String(char value[], int offset, int count)
複製程式碼

需要說明的是,String會根據引數新建立一個陣列,並拷貝內容,而不會直接用引數中的字元陣列。

String中的大部分方法,內部也都是操作的這個字元陣列。比如說:

  • length()方法返回的就是這個陣列的長度
  • substring()方法就是根據引數,呼叫構造方法String(char value[], int offset, int count)新建了一個字串
  • indexOf查詢字元或子字串時就是在這個陣列中進行查詢

這些方法的實現大多比較直接,我們就不贅述了。

String中還有一些方法,與這個char陣列有關:

返回指定索引位置的char

public char charAt(int index)
複製程式碼

返回字串對應的char陣列

public char[] toCharArray()
複製程式碼

注意,返回的是一個拷貝後的陣列,而不是原陣列。

將char陣列中指定範圍的字元拷貝入目標陣列指定位置

public void getChars(int srcBegin, int srcEnd, char dst[], int dstBegin) 
複製程式碼

按Code Point處理字元

與Character類似,String類也提供了一些方法,按Code Point對字串進行處理。

public int codePointAt(int index)
public int codePointBefore(int index)
public int codePointCount(int beginIndex, int endIndex)
public int offsetByCodePoints(int index, int codePointOffset)
複製程式碼

這些方法與我們在剖析Character一節介紹的非常類似,本節就不再贅述了。

編碼轉換

String內部是按UTF-16BE處理字元的,對BMP字元,使用一個char,兩個位元組,對於增補字元,使用兩個char,四個位元組。我們在第六節介紹過各種編碼,不同編碼可能用於不同的字符集,使用不同的位元組數目,和不同的二進位制表示。如何處理這些不同的編碼呢?這些編碼與Java內部表示之間如何相互轉換呢?

Java使用Charset這個類表示各種編碼,它有兩個常用靜態方法:

public static Charset defaultCharset()
public static Charset forName(String charsetName) 
複製程式碼

第一個方法返回系統的預設編碼,比如,在我的電腦上,執行如下語句:

System.out.println(Charset.defaultCharset().name());
複製程式碼

輸出為UTF-8

第二方法返回給定編碼名稱的Charset物件,與我們在第六節介紹的編碼相對應,其charset名稱可以是:US-ASCII, ISO-8859-1, windows-1252, GB2312, GBK, GB18030, Big5, UTF-8,比如:

Charset charset = Charset.forName("GB18030");
複製程式碼

String類提供瞭如下方法,返回字串按給定編碼的位元組表示:

public byte[] getBytes()  
public byte[] getBytes(String charsetName)
public byte[] getBytes(Charset charset) 
複製程式碼

第一個方法沒有編碼引數,使用系統預設編碼,第二方法引數為編碼名稱,第三個為Charset。

String類有如下構造方法,可以根據位元組和編碼建立字串,也就是說,根據給定編碼的位元組表示,建立Java的內部表示。

public String(byte bytes[])
public String(byte bytes[], int offset, int length)
public String(byte bytes[], int offset, int length, String charsetName)
public String(byte bytes[], int offset, int length, Charset charset)
public String(byte bytes[], String charsetName)
public String(byte bytes[], Charset charset)
複製程式碼

除了通過String中的方法進行編碼轉換,Charset類中也有一些方法進行編碼/解碼,本節就不介紹了。重要的是認識到,Java的內部表示與各種編碼是不同的,但可以相互轉換。

不可變性

與包裝類類似,String類也是不可變類,即物件一旦建立,就沒有辦法修改了。String類也宣告為了final,不能被繼承,內部char陣列value也是final的,初始化後就不能再變了。

String類中提供了很多看似修改的方法,其實是通過建立新的String物件來實現的,原來的String物件不會被修改。比如說,我們來看concat()方法的程式碼:

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);
}
複製程式碼

通過Arrays.copyOf方法建立了一塊新的字元陣列,拷貝原內容,然後通過new建立了一個新的String。關於Arrays類,我們將在後續章節詳細介紹。

與包裝類類似,定義為不可變類,程式可以更為簡單、安全、容易理解。但如果頻繁修改字串,而每次修改都新建一個字串,效能太低,這時,應該考慮Java中的另兩個類StringBuilder和StringBuffer,我們在下節介紹它們。

常量字串

Java中的字串常量是非常特殊的,除了可以直接賦值給String變數外,它自己就像一個String型別的物件一樣,可以直接呼叫String的各種方法。我們來看程式碼:

System.out.println("老馬說程式設計".length());
System.out.println("老馬說程式設計".contains("老馬"));
System.out.println("老馬說程式設計".indexOf("程式設計"));
複製程式碼

實際上,這些常量就是String型別的物件,在記憶體中,它們被放在一個共享的地方,這個地方稱為字串常量池,它儲存所有的常量字串,每個常量只會儲存一份,被所有使用者共享。當通過常量的形式使用一個字串的時候,使用的就是常量池中的那個對應的String型別的物件

比如說,我們來看程式碼:

String name1 = "老馬說程式設計";
String name2 = "老馬說程式設計";
System.out.println(name1==name2);
複製程式碼

輸出為true,為什麼呢?可以認為,”老馬說程式設計”在常量池中有一個對應的String型別的物件,我們假定名稱為laoma,上面程式碼實際上就類似於:

String laoma = new String(new char[]{`老`,`馬`,`說`,`編`,`程`});
String name1 = laoma;
String name2 = laoma;
System.out.println(name1==name2);
複製程式碼

實際上只有一個String物件,三個變數都指向這個物件,name1==name2也就不言而喻了。

需要注意的是,如果不是通過常量直接賦值,而是通過new建立的,==就不會返回true了,看下面程式碼:

String name1 = new String("老馬說程式設計");
String name2 = new String("老馬說程式設計");
System.out.println(name1==name2);
複製程式碼

輸出為false,為什麼呢?上面程式碼類似於:

String laoma = new String(new char[]{`老`,`馬`,`說`,`編`,`程`});
String name1 = new String(laoma);
String name2 = new String(laoma);
System.out.println(name1==name2);
複製程式碼

String類中以String為引數的構造方法程式碼如下:

public String(String original) {
    this.value = original.value;
    this.hash = original.hash;
}
複製程式碼

hash是String類中另一個例項變數,表示快取的hashCode值,我們待會介紹。

可以看出, name1和name2指向兩個不同的String物件,只是這兩個物件內部的value值指向相同的char陣列。其記憶體佈局大概如下所示:

計算機程式的思維邏輯 (29) – 剖析String

所以,name1==name2是不成立的,但name1.equals(name2)是true。

hashCode

我們剛剛提到hash這個例項變數,它的定義如下:

private int hash; // Default to 0
複製程式碼

它快取了hashCode()方法的值,也就是說,第一次呼叫hashCode()的時候,會把結果儲存在hash這個變數中,以後再呼叫就直接返回儲存的值。

我們來看下String類的hashCode方法,程式碼如下:(如果用掘金app看,可能會有亂碼,是掘金bug,可以通過掘金PC版檢視,或者關注我的微信公眾號”老馬說程式設計”檢視)

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;
}
複製程式碼

如果快取的hash不為0,就直接返回了,否則根據字元陣列中的內容計算hash,計算方法是:

s[0]*31^(n-1) + s[1]*31^(n-2) + ... + s[n-1]
複製程式碼

s表示字串,s[0]表示第一個字元,n表示字串長度,s[0]*31^(n-1)表示31的n-1次方再乘以第一個字元的值。

為什麼要用這個計算方法呢?這個式子中,hash值與每個字元的值有關,每個位置乘以不同的值,hash值與每個字元的位置也有關。使用31大概是因為兩個原因,一方面可以產生更分散的雜湊,即不同字串hash值也一般不同,另一方面計算效率比較高,31*h與32*h-h(h<<5)-h等價,可以用更高效率的移位和減法操作代替乘法操作。

在Java中,普遍採用以上思路來實現hashCode。

正規表示式

String類中,有一些方法接受的不是普通的字串引數,而是正規表示式,什麼是正規表示式呢?它可以理解為一個字串,但表達的是一個規則,一般用於文字的匹配、查詢、替換等,正規表示式有著豐富和強大的功能,是一個比較龐大的話題,我們將在後續章節單獨介紹。

Java中有專門的類如Pattern和Matcher用於正規表示式,但對於簡單的情況,String類提供了更為簡潔的操作,String中接受正規表示式的方法有:

分隔字串

public String[] split(String regex) 
複製程式碼

檢查是否匹配

public boolean matches(String regex)
複製程式碼

字串替換

public String replaceFirst(String regex, String replacement)
public String replaceAll(String regex, String replacement) 
複製程式碼

小結

本節,我們介紹了String類,介紹了其基本用法,內部實現,編碼轉換,分析了其不可變性,常量字串,以及hashCode的實現。

本節中,我們提到,在頻繁的字串修改操作中,String類效率比較低,我們提到了StringBuilder和StringBuffer類。我們也看到String可以直接使用+和+=進行操作,它們的背後也是StringBuilder類。

讓我們下節來看下這兩個類。


未完待續,檢視最新文章,敬請關注微信公眾號“老馬說程式設計”(掃描下方二維碼),深入淺出,老馬和你一起探索Java程式設計及計算機技術的本質。用心原創,保留所有版權。

計算機程式的思維邏輯 (29) – 剖析String

相關文章