pupuk / blog

My New Blog. Record & Share. Focus on PHP, MySQL, Javascript and Golang.
MIT License
9 stars 2 forks source link

细数PHP函数trim的那些怪异行为 #32

Open pupuk opened 3 years ago

pupuk commented 3 years ago

echo trim('王者荣耀、','、'); 结果:王者荣桠

echo rtrim('www.hptest.net', 'http'); 结果:www.hptest.ne

echo trim('abc', 'b'); 结果:abc

echo trim('abc','bad'); 结果:c

echo trim('abcd..ef', '.'); 结果:abcd..ef

echo trim('abcd..ef', '..'); 结果:报错了。

PHP Warning:  trim(): Invalid '..'-range, no character to the left of '..' in Unknown on line 0
cabcd..ef

echo trim('abcd..ef', 'a..c'); 结果:d..ef

这还是我们熟悉的trim函数嘛?附上鸟哥箴言 image

首先需要明确的一点,trim不是str_repalce,不是mb_str_replace. PHP 官方文档对trim函数的定义: trim ( string $str [, string $character_mask = " \t\n\r\0\x0B" ] ) : string

Optionally, the stripped characters can also be specified using the character_mask parameter. Simply list all characters that you want to be stripped. With .. you can specify a range of characters.

举个例子: echo trim('abcdcba','abc'); 实际上,我们要trim掉的字符不是abc整体,是 'a'|'b'|'c'

先说结论,

1、当有第二个参数的时候,第二个参数其实是一个需要trim掉charlist。其实在第二个参数缺省的时候,相当于第二个参数的chartlist等于[' '|"\t"|"\n"|"\r"|"\0"|"x0B"],第二个参数支持range写法,如a..c 表是的就是abc,a..z表示的就是小写a到z的26个字母。 2、ltrim是从左边开始,rtrim是从右边开始,trim是从两边往中间;如果发现一个字符在charlist里面,则替换成空,如果碰到一个字符不在charlist里面,则停止搜索; 3、无论从那个方向开始搜索,每次比较的最小单位是字节,是1byte。因此trim系列函数,用于多字节字符,包括汉字,可能出现预期之外结果。

案例分析

ASCII字符

echo rtrim('www.hptest.net', 'http'); 结果:www.hptest.ne 解释:从右边往左边搜索,右->左,t在[h|t|t|p]中,替换继续;e不在[h|t|t|p]中,停止;

echo trim('abc', 'b'); 结果:abc 解释:左->右,a不在,停止;右->左,c不在,停止;

echo trim('abc','bad'); 结果:c 解释:左->右,a在替换继续,b在替换继续,c停止;右->左,c不在停止;

echo trim('abcd..ef', '.'); 结果:abcd..ef 解释:左->右,a不等于. 停止;右->左,f不等于. 停止;

echo trim('abcd..ef', '..'); 结果:报错了。 解释:.. 两个点好表示range的写法,是函数的保留字符;

最后看多字节的

echo trim('王者荣耀、','、'); 王者荣耀、 对应的unicode是

E7 8E 8B 王
E8 80 85 者
E8 8D A3 荣
E8 80 80 耀
E3 80 81 、

上述函数可以等价为: trim("E7 8E 8B E8 80 85 E8 8D A3 E8 80 80 E3 80 81", "E3 80 81") 从左搜索,E7不在"E3 80 81"里面,停止 从右搜索,81有替换成空,80有替换成空,E3有替换成空,80有替换成空,80有替换成空,E8没有,停止搜索。

E7 8E 8B E8 80 85 E8 8D A3 E8 00 00 输出乱码为: E7 8E 8B E8 80 85 E8 8D A3 E6 A1 A0 E8 00 00 如何乱码成:E6 A1 A0,这块乱码的原理暂时还没有搞清楚; 但是经过验证:内存中存放的不是unicode字符,因为这样trim的结果更不对: 如果是uicode 王者荣耀、对应:

73 8B 王
80 05 者
83 63 荣
80 00 耀
30 01 、

trim更不可能对。

查询字符对应的码点,unicode编码,utf8编码:https://graphemica.com/%E7%8E%8B 参考博文:字符集与编码

疑问?trim函数是我们想的这样,先左->右,再右->左,不同顺序有影响吗? 从数据和算法来推测,先左->右,再右->左,没有什么区别,为了理解方便,可能会,先左再右。 不如来:

源码分析

源码面前,了无秘密

1、下载源码;从https://github.com/php/php-src git clone,切到需要的版本分支,我这里以master分支为例; 2、搜索PHP_FUNCTION(trim),得到:

/* {{{ Strips whitespace from the beginning and end of a string */
PHP_FUNCTION(trim)
{
    php_do_trim(INTERNAL_FUNCTION_PARAM_PASSTHRU, 3);
}
/* }}} */

/* {{{ Removes trailing whitespace */
PHP_FUNCTION(rtrim)
{
    php_do_trim(INTERNAL_FUNCTION_PARAM_PASSTHRU, 2);
}
/* }}} */

/* {{{ Strips whitespace from the beginning of a string */
PHP_FUNCTION(ltrim)
{
    php_do_trim(INTERNAL_FUNCTION_PARAM_PASSTHRU, 1);
}
/* }}} */

3、搜索php_do_trim,发现调用的是php_trim_int函数,搜索php_trim_int,发现其代码,我已注释:

/* {{{ php_trim_int()
 * mode 1 : trim left
 * mode 2 : trim right
 * mode 3 : trim left and right
 * what indicates which chars are to be trimmed. NULL->default (' \t\n\r\v\0')
 */
static zend_always_inline zend_string *php_trim_int(zend_string *str, const char *what, size_t what_len, int mode)
{
    const char *start = ZSTR_VAL(str); //字符串开始位置
    const char *end = start + ZSTR_LEN(str); //字符串结束位置
    char mask[256];

    // what 是要trim掉的字符串
    if (what) {
        if (what_len == 1) { //需要trim的是单个字符
            char p = *what; //p是what指针,指向的字符
            if (mode & 1) { //按位与,trim left
                while (start != end) {
                    if (*start == p) {
                        //从开始位置,向右移动指针,如果指向的字符=p,继续移动指针
                        start++;
                    } else {
                        break;
                    }
                }
            }
            if (mode & 2) {//按位与,trim right
                while (start != end) {
                    if (*(end-1) == p) {
                        //从末位位置,向左移动指针,如果指向的字符=p,继续移动指针
                        end--;
                    } else {
                        break;
                    }
                }
            }
            // 为什么没有考虑mode=3的情况呢?因为 3&1=1, 3&2=2,都是true,mode=3的时候
            // 上面两个if都为真,相当于先trim left,再trim right
        } else {
            //需要trim的是多个字符,是一个charlist
            php_charmask((const unsigned char *) what, what_len, mask); //创建一个掩码hash

            if (mode & 1) {
                while (start != end) {
                    if (mask[(unsigned char)*start]) {
                        //start指向的字符,在掩码hash里面,继续向右移动start
                        start++;
                    } else {
                        break;
                    }
                }
            }
            if (mode & 2) {
                while (start != end) {
                    if (mask[(unsigned char)*(end-1)]) {
                        //end-1指向的字符,在掩码hash里面,继续向左移动end
                        end--;
                    } else {
                        break;
                    }
                }
            }
        }
    } else { //trim,ltrim,rtrim等函数不使用第二个参数的情况
        if (mode & 1) { //按位与,trim left
            while (start != end) {
                unsigned char c = (unsigned char)*start; //指针在开始位置

                if (c <= ' ' &&
                    (c == ' ' || c == '\n' || c == '\r' || c == '\t' || c == '\v' || c == '\0')) {
                    start++;
                } else {
                    break;
                }
            }
            //从开始位置,移动指针,如果字符c<= ' '(即asc码小于32的字符) ,或者c是6种之一的空白字符
            //,继续向右移动指针,直到不匹配
        }
        if (mode & 2) { //按位与,trim right
            while (start != end) {
                unsigned char c = (unsigned char)*(end-1); //指针在末尾

                if (c <= ' ' &&
                    (c == ' ' || c == '\n' || c == '\r' || c == '\t' || c == '\v' || c == '\0')) {
                    end--;
                } else {
                    break;
                }
            }
        }
    }

    if (ZSTR_LEN(str) == end - start) { //字符原始长度= 新末尾-新开始,没有任何trim
        return zend_string_copy(str); //返回字符串的zend_string_copy
    } else if (end - start == 0) { //完全trim干净了
        return ZSTR_EMPTY_ALLOC(); //剩下一个空字符
    } else {
        // 有部分trim的情况,字符串=新开始位置+新长度, 这就能描述一个字符串了
        // 第三个参数0应该是,persistent,涉及字符串内存的分配的一个辅助参数
        return zend_string_init(start, end - start, 0);
    }
}
/* }}} */

纵观代码,思路还是非常清晰的 image 有what的情况下,分开处理单个字符和多个字符,是因为单个字符没有必要申请内存去创建一个charmask

最后根据情况返回字符串,其实字符串也就是start+len就能表示了。

php_charmask函数在下面链接 :696行 https://github.com/php/php-src/blob/master/ext/standard/string.c

至此,关于php trim的奥秘全部解开。