PHP內核探索之變量- 不平凡的字符串
切,一個字符串有什麼好研究的。
別這麼說,看過《平凡的世界》麼,平凡的字符串也可以有不平凡的故事。試看:
(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;
}
由於文章篇幅問題,更多的問題,這裡不再細說。還是那句話,有任何問題,歡迎指出。