切,一個字符串有什麼好研究的。
別這麼說,看過《平凡的世界》麼,平凡的字符串也可以有不平凡的故事。試看:
(1) 在C語言中,strlen計算字符串的時間復雜度是?PHP中呢?
(2) 在PHP中,怎樣處理多字節字符串?PHP對unicode的支持如何?
同樣是字符串,為什麼c語言與C++/PHP/Java的均不相同?
數據結構決定算法,這句話一點不假。
那麼我們今天就來掰一掰,PHP中的字符串結構,以及相關字符串函數的實現。
字符串可以說是PHP中遇到最多的數據結構之一了(另外一個比較常用的是數組,見PHP內核探索之變量(4)- 數組操作)。而由於PHP語言的特性和應用場景,使得我們日常的很多工作,實際上都是在處理字符串。也正是這個原因,PHP為開發者提供了豐富的字符串操作函數(初步統計約有100個,這個數量相當可觀)。那麼,在PHP中,字符串是怎樣實現的呢?與C語言又有什麼區別呢?
1. PHP中字符串的表現形式
在PHP中使用字符串有四種常見的形式:
(1) 雙引號
這種形式比較常見:$str=”this is \0 a string”; 而且以雙引號包含的字符串中可以包含變量、控制字符等:$str = "this is $name, aha.\n";
(2) 單引號
單引號包含的字符都被認為是raw的,因此不會解析單引號中的變量,控制字符等:
$string = "test"; $str = 'this is $string, aha\n'; echo $str;
(3) Heredoc
Heredoc比較適合較長的字符串表示,且對於多行的字符串表示更加靈活多樣。與雙引號表示形式類似,heredoc中也可以包含變量。常見的形式是:
$string ="test string"; $str = <<<STR This is a string \n, My string is $string STR; echo $str;
(4) nowdoc(5.3+支持)
nowdoc和heredoc是如此的類似,以至於我們可以把它們當做是一對兒親兄弟。nowdoc的起始標志符是用單引號括起來的,與單引號相似,它不會解析其中的變量,格式控制符等:
$s = <<<'EOT' this is $str this is \t test; EOT; echo $s;
2. PHP中字符串的結構
之前提到過,PHP中變量是用Zval(PHP內核探索之變量(1)Zval)這樣一個結構體來存儲的。Zval的結構是:
struct _zval_struct { zvalue_value value; /* value */ zend_uint refcount__gc; /* variable ref count */ zend_uchar type; /* active type */ zend_uchar is_ref__gc; /* if it is a ref variable */ };
而變量的值是zvalue_value這樣一個共用體:
typedef union _zvalue_value { long lval; double dval; struct { /* string */ char *val; int len; } str; HashTable *ht; zend_object_value obj; } zvalue_value;
我們從中抽取出字符串的結構:
struct { char *val; int len; } str;
現在比較清楚了,PHP中字符串在底層實際上是一個結構體,該結構體包含了指向字符串的指針和該字符串的長度。
那麼為什麼這麼做呢?換句話說,這樣做有什麼好處呢?我們接下來,將PHP的字符串與C語言的字符串做一個對比,以解釋采用這樣一種結構來存儲字符串的優勢。
3. 與c語言字符串的比較
我們知道,在c語言中,一個字符串可以用兩種常見的形式存儲,一種是使用指針方式,另一種是使用字符數組。我們接下來的說明,都以c語言的字符數組的方式來存儲字符串。
(1) PHP字符串是二進制安全的,而C字符串不是。
我們經常會提到”二進制安全”這一術語,那麼二進制安全究竟是什麼意思呢?
wikipedia中對二進制安全(Binary Safe)的定義是:
Binary-safe is a computer programming term mainly used in connection with string manipulating functions.
A binary-safe function is essentially one that treats its input as a raw stream of data without any specific format.
It should thus work with all 256 possible values that a character can take (assuming 8-bit characters).
翻譯過來就是:
二進制安全是計算機編程的術語,主要用於字符串操作函數。一個二進制安全的函數,本質上是指它將輸入看做是原始的數據流(raw)而不包含任何特殊的格式。
那麼為什麼C字符串不是二進制安全的?我們知道,在C語言中,以字符數組表示的字符串總是以\0結尾的,這個\0便是C字符串的specific format, 用於標識字符串的結束。更近一步說,如果一個字符串中本身包含了\0且並不是該字符串的結尾,那麼在C中,\0後面的所有數據都會被忽略(感覺就像是 字符串被莫名其妙的截斷了)。這也意味著,C字符串只合適保存簡單的文本,而不能用於保存圖片、視頻、其他文件等二進制數據。而在PHP中,我們可以使用$str = file_get_contents(“filename”);保存圖片、視頻等二進制數據。
(2) 效率對比。
由於C字符串中使用\0來標志字符串的結束,因此,對於strlen函數而言,獲取字符串長度的操作需要順序遍歷字符串,直到遇到\0為止,因此strlen函數的時間復雜度是O(n)。而在PHP中,字符串是以:
struct{ char *val; int len; } str;
這樣一種結構體來表示的,因而獲取字符串的長度只需要通過常量的時間便可以完成:
#define Z_STRLEN(zval) (zval).value.str.len
當然,僅僅是strlen函數的性能,無法支持“PHP中string比c字符串的效率更高”的結論(一個很明顯的原因是PHP是構建在C語言之上的高級語言),而僅僅說明,在時間復雜度上,PHP字符串比C字符串更加高效。
(3) 很多C字符串函數存在緩沖區溢出的漏洞
緩存區溢出是C語言中常見的漏洞,這種安全隱患經常是致命的。一個典型的緩存區溢出的例子如下:
void str2Buf(char *str) { char buffer[16]; strcpy(buffer,str); }
這個函數將str的內容copy到buffer數組中,而buffer數組的大小是16,因此如果str的長度大於16,便會發生緩沖區溢出的問題。
除了strcpy,還有gets, strcat, fprintf等字符串函數也會有緩沖區溢出的問題。
PHP中並沒有strcpy與strcat之類的函數,實際上由於PHP語言的簡潔性,並不需要提供strcpy和strcat之類的函數。例如我們要復制一個字符串,直接使用=即可:
$str = "this is a string"; $str_copy = $str;
由於PHP中變量共享zval的特性,並不會有空間浪費.而簡單的.連接符可以輕松實現字符串連接:
$str = "this is"; $str .= "test string"; echo $str;
關於字符串連接符過程中的內存分配和管理,可以查看zend引擎部分的實現,這裡暫時忽略。
毫無疑問,研究字符串的目的並不只是為了知道它的結構和特性,而是為了更好的使用它。我們日常的工作中,恐怕有一般以上的工作都是在與字符串打交道:如處理一個日期串、加密一個密碼、獲取用戶信息、正則表達式匹配替換、字符串替換、格式化一個串等等。可以說,在PHP開發中,你無法避免與字符串的直接或者間接接觸(就像無法擺脫呼吸)。正因為如此,PHP為開發者提供了大量的、豐富的字符串操作函數( http://cn2.php.net/manual/en/ref.strings.php),這對於90%以上的字符串操作,已經基本足夠。
由於字符串函數眾多,不可能一一說明。這裡只挑選幾個比較典型的字符串操作函數 來做簡單的說明(我相信80%以上的PHPer對於字符串的操作函數掌握的非常的好)。
在開始說明之前,有必要強調一下字符串函數的使用原則,理解和掌握這些原則對於高效、熟練使用字符串函數非常關鍵,這些原則包括(不僅限於):
(1) 如果你的操作既可以使用正則表達式,也可以使用字符串。那麼優先考慮字符串操作。
正則表達式是處理文本的絕好工具,尤其對於模式查找、模式替換這一類應用,正則可以說是無往不利。正因為如此,正則表達式在很多場合都被濫用。如果對於你的字符串操作,既可以使用字符串函數完成,也可以使用正則表達式完成,那麼,請優先選擇字符串操作函數,因為正則表達式在一定場合下會有嚴重的性能問題。
(2) 注意false與0
PHP是弱變量類型,相信不少phper開始都深受其害
var_dump( 0 == false);//bool(true) var_dump( 0 === false);//bool(false)
等等,這與字符串操作函數有什麼關系?
在PHP中,有一類函數用於查找(如strpos, stripos),這類查找函數在查找成功時,返回的是子串在原串中的index,如strpos:
var_dump(strpos("this is abc", "abc"));
而在查找不成功時,返回的是false:
var_dump(strpos("this is abc", "angle"));//false
這裡便有一個坑:字符串的索引也是以0開始的!如果子串剛好在源串的起始位置出現,那麼,簡單的==比較便無法區分究竟strpos是不是成功:
var_dump(strpos("this is abc", "this"));
因此我們一定是要用===來比較的:
if((strpos("this is abc", "this")) === false){ // not found }
(3) 多看手冊,避免重復造輪子。
相信不少PHPer面試都碰到過這樣的問題:如何翻轉一個字符串?由於題目中只提及“如何“,而並沒有限制”不使用PHP內置函數“。那麼對於本題,最簡潔的方法自然是使用strrev函數。另一個說明不應該重復造輪子的函數是levenshtein函數,這個函數如同其名字一樣,返回的是兩個字符串的編輯距離。作為動態規劃(DP)的典型代表案例之一,我想編輯距離很多人都不陌生。碰到這類問題,你還准備DP搞起嗎?一個函數搞定它:
$str1 = "this is test"; $str2 = "his is tes"; echo levenshtein($str1, $str2);
在某些情況下,我們都應該盡可能的“懶“,不是嗎。
以下是字符串操作函數節選(對於最常見的操作,請直接參考手冊)
1. strlen
此標題一出,我猜想大多數人的表情是這樣的:
或者是這樣的:
我要說的,並不是這個函數本身,而是這個函數的返回值。
int strlen ( string $string ) Returns the length of the given string.
雖然手冊上明確指出“strlen函數返回給定字符串的長度”,但是,並沒有對長度單位做任何說明,長度究竟是指”字符的個數“還是說”字符的字節數“。而我們要做的,並不是臆想,而是測試:
在GBK編碼格式下:
echo strlen(“這是中文”);//8
說明strlen函數返回的是字符串的字節數。那麼又有問題了,如果是utf-8編碼,由於中文在utf8編碼的情況下,每個中文使用3個byte,因而,我們期望的結果應該是12:
echo strlen(“這是中文”);//12
這說明:strlen計算字符串的長度依賴於當前的編碼格式,其值並不是唯一的!這在某些情況下,自然是無法滿足要求的。這時,多字節擴展mbstring便有它的發揮余地了:
echo mb_strlen("這是中文", "GB2312");//4
關於這點,在多字節處理中會有相應說明,這裡略過。
2. str_word_count
str_word_count是另一個比較強大的且容易忽略的字符串函數。
mixed str_word_count ( string $string [, int $format = 0 [, string $charlist ]] )
其中$format的不同值可以使str_word_count函數有不同的行為。 現在,我們手頭有這樣的文本:
When I am down and, oh my soul, so weary When troubles come and my heart burdened be Then, I am still and wait here in the silence Until you come and sit awhile with me You raise me up, so I can stand on mountains You raise me up, to walk on stormy seas I am strong, when I am on your shoulders You raise me up… To more than I can ber You raise me up, so I can stand on mountains You raise me up, to walk on stormy seas I am strong, when I am on your shoulders You raise me up, To more than I can be。
那麼:
(1)$format = 0
$format=0, $format返回的是文本中的單詞的個數:
echo str_word_count(file_get_contents(“word”)); //112
(2)$format = 1
$format=1時,返回的是文本中全部單詞的數組:
print_r(file_get_contents(“word”),1 );
Array ( [0] => When [1] => I [2] => am [3] => down [4] => and [5] => oh [6] => my [7] => soul [8] => so [9] => weary [10] => When [11] => troubles ...... )
這一特性有什麼作用呢?比如英文分詞。還記得“單詞統計”的問題麼?str_word_count可以輕松完成單詞統計TopK的問題:
$s = file_get_contents("./word"); $a = array_count_values(str_word_count($s, 1)) ; arsort( $a ); print_r( $a ); /* Array ( [I] => 10 [me] => 7 [raise] => 6 [up] => 6 [You] => 6 [am] => 6 [on] => 6 [can] => 4 [and] => 4 [be] => 3 [so] => 3 …… );*/
(3)$format = 2
$format=2時,返回的是一個關聯數組:
$a = str_word_count($s, 2); print_r($a); /* Array ( [0] => When [5] => I [7] => am [10] => down [15] => and [20] => oh [23] => my [26] => soul [32] => so [35] => weary [41] => When [46] => troubles [55] => come ... )*/
配合其他數組函數,可以實現更加多樣化的功能.例如,配合array_flip,可以計算某個單詞最後一次出現的位置:
$t = array_flip(str_word_count($s, 2)); print_r($t);
而如果配合了array_unique之後再array_flip,則可以計算某個單詞第一次出現的位置:
$t = array_flip( array_unique(str_word_count($s, 2)) ); print_r($t); Array ( [When] => 0 [I] => 5 [am] => 7 [down] => 10 [and] => 15 [oh] => 20 [my] => 23 [soul] => 26 [so] => 32 [weary] => 35 [troubles] => 46 [come] => 55 [heart] => 67 ... )
3. similar_text
這是除了levenshtein()函數之外另一個計算兩個字符串相似度的函數:
int similar_text ( string $first , string $second [, float &$percent ] )
$t1 = "You raise me up, so I can stand on mountains"; $t2 = "You raise me up, to walk on stormy seas"; $percent = 0; echo similar_text($t1, $t2, $percent).PHP_EOL;//26 echo $percent;// 62.650602409639
撇開具體的使用不談,我很好奇底層對於字符串的相似度是如何定義的。
Similar_text函數實現位於 ext/standard/string.c 中,摘取其關鍵代碼:
PHP_FUNCTION(similar_text){ char *t1, *t2; zval **percent = NULL; int ac = ZEND_NUM_ARGS(); int sim; int t1_len, t2_len; /* 參數解析 */ if (zend_parse_parameters(ZEND_NUM_ARGS() TSRMLS_CC, "ss|Z", &t1, &t1_len, &t2, &t2_len, &percent) == FAILURE) { return; } /* set percent to double type */ if (ac > 2) { convert_to_double_ex(percent); } /* t1_len == 0 && t2_len == 0 */ if (t1_len + t2_len == 0) { if (ac > 2) { Z_DVAL_PP(percent) = 0; } RETURN_LONG(0); } /* 計算字符串相同個數 */ sim = php_similar_char(t1, t1_len, t2, t2_len); /* 相似百分比 */ if (ac > 2) { Z_DVAL_PP(percent) = sim * 200.0 / (t1_len + t2_len); } RETURN_LONG(sim); }
可以看出,字符串相似個數是通過 php_similar_char 函數實現的,而相似百分比則是通過公式:
percent = sim * 200 / (t1串長度 + t2串長度)
來定義的。
php_similar_char的具體實現:
static int php_similar_char(const char *txt1, int len1, const char *txt2, int len2) { int sum; int pos1 = 0, pos2 = 0, max; php_similar_str(txt1, len1, txt2, len2, &pos1, &pos2, &max); if ((sum = max)) { if (pos1 && pos2) { sum += php_similar_char(txt1, pos1,txt2, pos2); } if ((pos1 + max < len1) && (pos2 + max < len2)) { sum += php_similar_char(txt1 + pos1 + max, len1 - pos1 - max,txt2 + pos2 + max, len2 - pos2 - max); } } return sum; }
這個函數通過調用php_similar_str來完成字符串相似個數的統計,而php_similar_str返回字符串s1與字符串s2的最長相同字符串長度:
static void php_similar_str(const char *txt1, int len1, const char *txt2, int len2, int *pos1, int *pos2, int *max) { char *p, *q; char *end1 = (char *) txt1 + len1; char *end2 = (char *) txt2 + len2; int l; *max = 0; /* 查找最長串 */ for (p = (char *) txt1; p < end1; p++) { for (q = (char *) txt2; q < end2; q++) { for (l = 0; (p + l < end1) && (q + l < end2) && (p[l] == q[l]); l++); if (l > *max) { *max = l; *pos1 = p - txt1; *pos2 = q - txt2; } } } }
php_similar_str匹配完成之後,原始的串被劃分為三個部分:
第一部分是最長串的左邊部分,這一部分含有相似串,但是卻不是最長的;
第二部分是最長相似串部分;
第三部分是最長串的右邊部分,與第一部分相似,這一部分含有相似串,但是也不是最長的。因而要遞歸對第一部分和第三部分求相似串的長度:
/* 最長的串左邊部分相似串 */ if (pos1 && pos2) { sum += php_similar_char(txt1, pos1,txt2, pos2); } /* 右半部分相似串 */ if ((pos1 + max < len1) && (pos2 + max < len2)) { sum += php_similar_char(txt1 + pos1 + max, len1 - pos1 - max, txt2 + pos2 + max, len2 - pos2 - max); }
匹配的過程如下圖所示:
對於字符串函數的更多解釋,可以參考PHP的在線手冊,這裡不再一一列舉。
迄今為止,我們討論的所有的字符串和相關操作函數都是單字節的。然而這個世界是如此的豐富多彩,就好比有紅瓤的西瓜也有黃瓤的西瓜一樣,字符串也不例外。如我們常用的中文漢字在GBK編碼的情況下,實際上是使用兩個字節來編碼的。多字節字符串不僅僅局限於中文漢字,還包括日文,韓文等等多個國家的文字。正因為如此,對於多字節字符串的處理顯得異常重要。
字符和字符集是編程過程中不可避免總是要遇到的術語。如果有童鞋對於這一塊的內容並不是特別清晰,建議移步《編碼大事1字符編碼基礎-字符和字符集,》
由於我們日常中使用較多的是中文,因而我們以中文字符串截取為例, 重點研究中文字符串的問題。
中文字符串的截取
中文字符串截取一直是個相對來說比較麻煩的問題,原因在於:
(1) PHP原生的substr函數只支持單字節字符串的截取,對於多字節的字符串略顯無力
(2) PHP的擴展mbstring需要服務器的支持,事實上,很多開發環境中並沒有開啟mbstring擴展,對於習慣使用mbstring擴展的童鞋非常遺憾。
(3) 一個更為復雜的問題是,在UTF-8編碼的情況下,雖然中文是3個字節的,但是中文的某些特殊字符(如脫字符·)實際上是雙字節編碼的。這無疑加大了中文字符串截取的難度(畢竟,中文字符串中不可能完全不包含特殊字符)。
頭疼之余,還是要自己撸一個中文的字符串截取的庫,這個字符串截取函數應該與substr有相似的函數參數列表,而且要支持中文GBK編碼和UTF-8編碼情況下的截取,為了效率起見,如果服務器已經開啟了mbstring擴展,那麼就應該直接使用mbstring的字符串截取。
API:
String cnSubstr(string $str, int $start, int $len, [$encode=’GBK’]);//注意參數中$start, $len都是字符數而不是字節數。
我們以UTF-8編碼為例,來說明UTF8編碼下中文的截取思路。
(1) 編碼范圍:
UTF-8的編碼范圍(utf-8使用1-6個字節編碼字符,實際上只使用了1-4字節):
1個字節:00——7F 2個字節:C080——DFBF 3個字符:E08080——EFBFBF 4個字符:F0808080——F7BFBFBF
據此, 可以根據第一個字節的范圍確定該字符所占的字節數:
$ord = ord($str{$i}); $ord < 192 單字節和控制字符 192 <= $ord < 224 雙字節 224<= $ord < 240 三字節 中文並沒有四個字節的字符
(2)$start為負的情況
if( $start < 0 ){ $start += cnStrlen_utf8( $str ); if( $start < 0 ){ $start = 0; } }
網上大多數字符串截取版本都沒有處理$start< 0的情況,按照PHP substr的API設計,在$start <0 時,應該加上字符串的長度(多字節指字符數)。
其中cnStrlen_utf8用於獲取字符串在utf8編碼下的字符數:
function cnStrlen_utf8( $str ){ $len = 0; $i = 0; $slen = strlen( $str ); while( $i < $slen ){ $ord = ord( $str{$i} ); if( $ord < 127){ $i ++; }else if( $ord < 224 ){ $i += 2; }else{ $i += 3; } $len ++; } return $len; }
因此UTF-8的截取算法為:
function cnSubstr_utf8( $str, $start, $len ){ if( $start < 0 ){ $start += cnStrlen_utf8( $str ); if( $start < 0 ){ $start = 0; } } $slen = strlen( $str ); if( $len < 0 ){ $len += $slen - $start; if($len < 0){ $len = 0; } } $i = 0; $count = 0; /* 獲取開始位置 */ while( $i < $slen && $count < $start){ $ord = ord( $str{$i} ); if( $ord < 127){ $i ++; }else if( $ord < 224 ){ $i += 2; }else{ $i += 3; } $count ++; } $count = 0; $substr = ''; /* 截取$len個字符 */ while( $i < $slen && $count < $len){ $ord = ord( $str{$i} ); if( $ord < 127){ $substr .= $str{$i}; $i ++; }else if( $ord < 224 ){ $substr .= $str{$i} . $str{$i+1}; $i += 2; }else{ $substr .= $str{$i} . $str{$i+1} . $str{$i+2}; $i += 3; } $count ++; } return $substr; }
而最終的cnSubstr()可以設計如下(程序還有很多優化的余地):
function cnSubstr( $str, $start, $len, $encode = 'gbk' ){ if( extension_loaded("mbstring") ){ //echo "use mbstring"; //return mb_substr( $str, $start, $len, $encode ); } $enc = strtolower( $encode ); switch($enc){ case 'gbk': case 'gb2312': return cnSubstr_gbk($str, $start, $len); break; case 'utf-8': case 'utf8': return cnSubstr_utf8($str, $start, $len); break; default: //do some warning or trigger error; } }
簡單的測試一下:
$str = "這是中文的字符串string,還有abs· "; for($i = 0; $i < 10; $i++){ echo cnSubstr( $str, $i, 3, 'utf8').PHP_EOL; }
最後貼一下ThinkPHP extend中提供的msubstr函數(這是用正則表達式做的substr):
function msubstr($str, $start=0, $length, $charset="utf-8", $suffix=true) { if(function_exists("mb_substr")) $slice = mb_substr($str, $start, $length, $charset); elseif(function_exists('iconv_substr')) { $slice = iconv_substr($str,$start,$length,$charset); if(false === $slice) { $slice = ''; } }else{ $re['utf-8'] = "/[\x01-\x7f]|[\xc2-\xdf][\x80-\xbf]|[\xe0-\xef][\x80-\xbf]{2}|[\xf0-\xff][\x80-\xbf]{3}/"; $re['gb2312'] = "/[\x01-\x7f]|[\xb0-\xf7][\xa0-\xfe]/"; $re['gbk'] = "/[\x01-\x7f]|[\x81-\xfe][\x40-\xfe]/"; $re['big5'] = "/[\x01-\x7f]|[\x81-\xfe]([\x40-\x7e]|\xa1-\xfe])/"; preg_match_all($re[$charset], $str, $match); $slice = join("",array_slice($match[0], $start, $length)); } return $suffix ? $slice.'...' : $slice; }
由於文章篇幅問題,更多的問題,這裡不再細說。還是那句話,有任何問題,歡迎指出。
參考文獻: