cJson之parse_string(四)

cJson之环境搭建(一) 快速搭建cJson项目环境

cJson之parse_number(二) 数字解析

cJson之parse_hex4(三) 4位16进制字符解析

从感觉上看字符串没啥好解析的,获取双引号里面的内容就是了,实际上涉及到转义字符、编码问题,导致有点复杂,所以内容比想的要多,我们先从一些基本概念开始

字符串标记

下面这个字符串cJson能解析出来吗?其实不能,下面是字符串不假,但字符串的内容时引号之间的字符,并不包括引号本身,所以cJson拿到是abcd字符,在测试用例中是知道这些内容的,但在完整的Json数据中,没有一个开始结束标志,它根本不知道从哪开始到哪结束

"hello"

也许你想到用特殊字符来标识字符串的开始和结束,比如下面这样,这样其它库解析就识别不了,而且^字符作为标识后,其本身也识别不了,可能还有其它问题

"^hello^"

而引号作为字符串默认的标识符是较好的选择,但也不能直接添加,需要通过转义字符\"表示是引号本身,传给cJson解析的正确字符串是下面这样的,这样cJson拿到就是\"abcd\",是六个字符,前后两个引号字符作为开始结束标记

"\"hello\""

所以在Json数据中看到的引号就是其字符本身

json 复制代码
{
    "hello": "world!"
}

转义字符

转义字符是以反斜杠'\'为开头的字符,后面跟一个或几个字符,其意思是将反斜杠'\'后面的字符转变成为另外的意义, 除了上面看到的转义字符\",还有下面这些

  • 退格符号 \b,同键盘上Backspace键
  • 换行并回车 \n、
  • 只回车不换行 \r,
  • 水平制表符 \t,同键盘上Tap键,
  • 表示Unicode编码 \u
  • 表示反斜杠本身 \\
  • 表示单引号字符 \'

这些看上去是两个字符,实际都只占一个字符

unicode

刚开始的时候只有ASCII字符集,它使用7 bits来表示一个字符,从00000000到011111111总共表示128个字符,这对英语国家来说勉强够有,但对于非字母国家来说就捉襟见肘了,比如我们汉字就有上万,那ASCII不能满足的情况下只好自己搞一套,我们中文字符集编码就有GBK、GB2312,但如果每个国家都搞一套,将会无法跨语言、跨平台,到处是乱码

所以国际组织统一了标准,定义了一个Unicode表,以满足跨语言、跨平台进行文本转换的要求。 表中为世界上每种语言中的每个字符设定了统一并且唯一的数字 (码位code point),范围从0000至FFFF(2的16次方:65535),这个范围叫基本多语言平面(BMP),其中0至127这128个数字表示的字符仍然跟ASCII完全一样。在表示一个Unicode的字符时,通常会用"U+"然后紧接着一组十六进制的数字来表示这一个字符,如下图

这样的Unicode非常适合用UTF-16方式编码的,也就是2个字节表示一个Unicode字符,如0x12ab,问题就是英文、数字这些字符都是一个字节可以表示的,如果数据的主要字符都在ASCII部分,那就吃大亏了。

目前主流是采用UTF-8编码,对于不同范围的Unicode,UTF-8采用的编码方式不一样,最少一个字节,最多四个字节,超过一个字节是有编码规则的,如下图

一般情况下我们设置好UTF-8编码,就不用管了,系统会在存储和显示之间自动编解码,比如存储的是0x0061,显示为'a'。但在解析字符串时,可能会遇到Unicode字符串:"\u0061",这就要将字面上的UTF-16转化成UTF-8格式存储

UTF-16转UTF-8

这种转换是分区间的,"\\u0061"通过parse_hex4函数转换成0x61,其在第一区间就不用其它操作,直接使用。

那如果是"\\u00D7",解析成16进制数字是0xD7,这个值就是code point,查询Unicode表可知是个✖号,那以UTF-8的方式怎么编码呢?

首先判断其在第二区间[0x80,0x7FFF],需要两个字节编码,根据上图,先将右边低六位加上'10'变成字节0x97,再把高5位加上'110'变成另一个字节0xC3,这样就转换成了0xC3、0x97两个字节,当你复制✖号的时候,其实就是复制这两个字节

那这两个字节发给其它平台,它怎么获取其code point呢?

其实是上面的一个逆过程,第一个字节的高三位是'110'标记,第二个字节的高两位是'10'标记,说明这两个字节表示一个Unicode字符,去除这个5个标记位,按顺序合并在一起,共有11位,如果都是1的话,16进制就是0x7FF,这就是为什么上限是0x7FF的原因。高位再添加5个0补齐,最后得到的code point就是0xD7,查询Unicode表可知是个✖号

如果你看parse_string.c中的测试用例,会发现还有这样的字符串"\\uD83D\\udc31",一般来说是挨个处理的,但操蛋的事情来了,随着字符不断增多,原来2个字节的范围不够用了,于是就加了区间[0x10000,0x10FFFF],这个范围叫增补平面(SP)。

那这个区间怎么得来的呢?原来0x0000到0xFFFF区间有一部分预留码位[0xD800,0xDFFF],共2048个,这群聪明蛋把它一分为二,用结对的方式解决增补字符问题,专业术语代理对(surrogate pair),共四个字节

  • 第一个16位在0xD800到0xDBFF之间,共1024个,有效位数是低10位,如下图黄色部分
  • 第二个16位在0xDC00到0xDFFF之间,共1024个,有效位数是低10位,如下图黄色部分。

上面一对UTF-16不会单独出现,必须成对出现,那能表示的字符个数就是1024x1024个,范围在[0x00000,0xFFFFF],增补范围在BMP范围之上,所以是从0xFFFF+1=0x10000开始的,也就是[0x10000,0x10FFFF],能表示一百多万个字符,代价就是每个得四个字节的空间啦,

UTF-32说整这么费事干嘛,全部都用四个字节编码算了,问题还是一样的,浪费空间啊,大兄弟。

所以如果遇到连续两个Unicode字符串,就得判断是否是代理对,解析后的两个16进制数值为0xD83D、0xdc31满足条件,需要当作整体处理。

那怎么计算其code point?

由于两个数值的起点都不是基于0的,所以需要先去除其起点

  • 0xD83D去除起点0xD800
  • 0xdc31去除起点0xDC00

怎么去除?一般的做法是直接减,更高效的办法是通过位运行,上面的区间图说过黄色的部分是有效值,将红色的高六位,置为0,而保留黄色的10位就可以了,具体操作就是 & 0x03FF

整个过程分下面三步,还是以0xD83D和0xdc31为例:

  • 分别&0x03FF得到0x3D和0x31
  • 将第一个有效10位和第二个有效十位如下图拼接起来
  • 将拼后的结果+0x10000就得到增补区间的code point了,数值不重要了,是个🐱,我从测试用例中知道的

有了code point,知道在第四区间,知道编码规则,就像之前介绍的方法一样操作,编码成4个字节

11110xxx 10xxxxxx 10xxxxxx 10xxxxxx,

字符串解析

理解了上面的基础知识,就比较容易理解字符串解析的思路,这里不贴全部代码了,采用分段的方式说下重点

  1. 判断解析的内容是不是引号字符开始的
arduino 复制代码
 if (buffer_at_offset(input_buffer)[0] != '\"')
{
    goto fail;
}
  1. 预估需要多少空间存储解析后的字符,粗略的估计是字符串总长度去除转义字符的数量
ini 复制代码
// buffer_at_offset(input_buffer)就是字符串起始位置
const unsigned char *input_end = buffer_at_offset(input_buffer) + 1;
size_t skipped_bytes = 0;
 while (((size_t)(input_end - input_buffer->content) < input_buffer->length) && (*input_end != '\"'))
{
    /* 判断当前字符是不是反斜杠,是的话计个数 */
    if (input_end[0] == '\\')
    {
        // 这句判断有问题 后面会说
        if ((size_t)(input_end + 1 - input_buffer->content) >= input_buffer->length)
        {
            goto fail;
        }
        skipped_bytes++;
        input_end++;
    }
    input_end++;
}
// 预估的长度, buffer_at_offset(input_buffer)就是字符串起始位置
allocation_length = (size_t) (input_end - buffer_at_offset(input_buffer)) - skipped_bytes;

比如下图,通过引号判断结束符,得到字符串长度为10,有两个反斜杠,所以预估的长度就是8个字节 3. 依次对字符解析,策略如下

  • 只要不是反斜杠直接复制
arduino 复制代码
 if (*input_pointer != '\\')
  {
      *output_pointer++ = *input_pointer++;
  }
  • 对于反斜杠,需要特别处理,其后面一个字符如果是b、f、n、r、t,赋值对应的转义字符即可,
arduino 复制代码
switch (input_pointer[1])
    case 'b':
      *output_pointer++ = '\b';
      break;
    case 'f':
      *output_pointer++ = '\f';
      break;
    case 'n':
      *output_pointer++ = '\n';
      break;
    case 'r':
      *output_pointer++ = '\r';
      break;
  case 't':
      *output_pointer++ = '\t';
      break;
  • 如果是引号、斜杠、反斜杠就直接复制
arduino 复制代码
  case '\"':
  case '\\':
  case '/':
     *output_pointer++ = input_pointer[1];
     break;
  • 如果是u,需要解析unicode字符串,这是最复杂的部分,涉及上面说的UTF-16到UTF-8的转换
ini 复制代码
  case 'u':
   sequence_length = utf16_literal_to_utf8(input_pointer, input_end, &output_pointer);

首先是获取code point,思路如下:

  • 如果遇到的第一个unicode字符串在0xDC00到0xDFFF之间,肯定是异常,只有第二个unicode在这个区间
  • 然后判断是不是代理对,代理对要满足条件,然后计算code point;不是代理对就简单了
ini 复制代码
    // 解析字符unicode字符串"\\uab12",+2是跳过\\u两个字符,第三节专门介绍过parse_hex4
    first_code = parse_hex4(first_sequence + 2);

    if (((first_code >= 0xDC00) && (first_code <= 0xDFFF)))
    {
        goto fail;
    }

    /* UTF16代理对 */
    if ((first_code >= 0xD800) && (first_code <= 0xDBFF))
    {
       ...
        second_code = parse_hex4(second_sequence + 2);
        if ((second_code < 0xDC00) || (second_code > 0xDFFF))
        {
            goto fail;
        }
        // 去高六位,第一个左移10位,拼接第二个10位,加上0x10000
        codepoint = 0x10000 + (((first_code & 0x3FF) << 10) | (second_code & 0x3FF));
    }
    else
    {
        // 不是代理对,code point就是解析的结果
        sequence_length = 6; /* \uXXXX */
        codepoint = first_code;
    }

有了code point就开始编码了,编码的规则上面也介绍过了,不同区间主要是首位的掩码不同

  • 0xxxxxxx
  • 110xxxxx 10xxxxxx
  • 1110xxxx 10xxxxxx 10xxxxxx
  • 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx
ini 复制代码
    if (codepoint < 0x80)
    {
        /* normal ascii, encoding 0xxxxxxx */
        utf8_length = 1;
    }
    else if (codepoint < 0x800)
    {
        /* two bytes, encoding 110xxxxx 10xxxxxx */
        utf8_length = 2;
        first_byte_mark = 0xC0; /* 11000000 */
    }
    else if (codepoint < 0x10000)
    {
        /* three bytes, encoding 1110xxxx 10xxxxxx 10xxxxxx */
        utf8_length = 3;
        first_byte_mark = 0xE0; /* 11100000 */
    }
    else if (codepoint <= 0x10FFFF)
    {
        /* four bytes, encoding 1110xxxx(这里注释有误,估计是直接复制上面的,应该是11110xxx) 10xxxxxx 10xxxxxx 10xxxxxx */
        utf8_length = 4;
        first_byte_mark = 0xF0; /* 11110000 */
    }
    else
    {
        /* invalid unicode codepoint */
        goto fail;
    }

先把最后一个字节到第二个字节给整成这样10xxxxxx,咋整呢?之前演示的方式是取低六位加上'10'就行了,这是为了直观理解,实际代码中没有能添加两个bit方法的,而是通过位操作修改

scss 复制代码
    for (utf8_position = (unsigned char)(utf8_length - 1); utf8_position > 0; utf8_position--)
    {
        /* 10xxxxxx */
        (*output_pointer)[utf8_position] = (unsigned char)((codepoint | 0x80) & 0xBF);
        codepoint >>= 6;
    }

主要有三步

  • 先将code point | 0x80,得到第一个1,如下图所示
  • 再将结果 & 0xBF,除了得到第二个0,高位也全部置0,这样就得到符合要求的字节了。也许你觉得这样改变了code point值,其实这两步操作只是使用了code point值,并没有改变其值,因为使用的是 | &,而不是|=、&=
  • 完成编码后,将低六位移出去,这次就改变其值了,使用的是>>=

最后一步就是处理第一个字节了,前面已经根据不同区间得到不同的首字节掩码first_byte_mark,比如第四区间的1111 0000,所以直接按位或操作即可,0xFF是保障高位全部置0

scss 复制代码
 if (utf8_length > 1)
{
    (*output_pointer)[0] = (unsigned char)((codepoint | first_byte_mark) & 0xFF);
}
else
{
    (*output_pointer)[0] = (unsigned char)(codepoint & 0x7F);
}

像这种在Json中放unicode字符串的比较少见,我刚工作的时候,就有后台开发给我传这样的Json数据,完全看不懂,可读性差,但Json库一般能解析出来,所以显示还是正常的,还不好找后台开发理论。

实际这种Unicode字符串是个中间状态,要显示就得解析并计算其code point,要存储还得使用UTF-8编码成字节,而且一个串"\uxxxx"有6个字符,占六个字节,使用UTF-8编码顶多3个字节。所以没有特殊需求,不要使用这种Unicode字符串。

单元测试

上传parse_string.c测试文件后,修改CMakeLists添加parse_string文件名即可编译运行

cd build

make

cd tests

./parse_string

bash 复制代码
#添加parse_string文件名即可,如果前面的不想要 可以删掉
set(unity_tests parse_number parse_hex4 parse_string)

单元测试中,尝试了各种类型的字符串,复杂的就是含转义字符和Unicode字符串,下面这个说明一下

  • \\b是\\和b两个字符,需要解析,转化成'\b'
  • 而\b就是原字符,可以直接使用,其它也一样,常见的应用就是打印语句中的printf("xxxxx \n")换行
swift 复制代码
 assert_parse_string(
        "\"\\\"\\\\\\/\\b\\f\\n\\r\\t\\u20AC\\u732b\"",
        "\"\\/\b\f\n\r\t€猫");

assert_parse_string("\"\b\f\n\r\t\"", "\b\f\n\r\t");

在前面统计字符个数中,我说源码中这句话有问题,它的作用是判断最后一个字符是不是斜杠,因为斜杠后面必须要有其它有效字符,否则就是异常,但这句话其实是判断不出来的

arduino 复制代码
// 这句判断有问题 后面会说
if ((size_t)(input_end + 1 - input_buffer->content) >= input_buffer->length)
{
    goto fail;
}

如下面图所示,input_end指向最后的一个字符时,它的索引是input_buffer->length - 2,input_buffer->content是开始,索引为0,带入左边式子就是

input_buffer->length-2+1-0 = input_buffer->length -1

input_buffer->length -1 是不可能 >= input_buffer->length的

应该把右边的改成input_buffer->length -1

下面这个用例是捕获最后一个反斜杠,能通过测试不是在统计字符个数时发现的,而是在复制到最后一刻发现的,这个就像下载岛国爱情片,用了几百M,下载到最后一个字节,告诉你:不行,看不了,给删了。

swift 复制代码
 assert_not_parse_string("\"000000000000000000\\");

写在最后

字符串解析是Json解析的一个重要基础,解析数组、对象虽然庞大,但都是以这个为基础的,然后是不断重复罢了。文章涉及到一些编码、二进制、十六进制、位操作,如果5分钟没看懂,不要沮丧,毕竟我写了十几个小时,如果5分钟就看懂了,我会很欣慰,这个时间没白花。

参考资料

(Unicode) UTF-8与UTF-16之间转换

Unicode:Surrogate Pairs UTF-16中用于扩展字符

UTF-8 到底是什么意思?unicode编码简介

相关推荐
djk88881 小时前
.net6.0(.net Core)读取 appsettings.json 配置文件
json·.net·.netcore
茶猫_2 小时前
力扣面试题 - 25 二进制数转字符串
c语言·算法·leetcode·职场和发展
ö Constancy2 小时前
Linux 使用gdb调试core文件
linux·c语言·vim
lb36363636362 小时前
介绍一下strncmp(c基础)
c语言·知识点
wellnw3 小时前
[linux] linux c实现共享内存读写操作
linux·c语言
芜湖_5 小时前
【山大909算法题】2014-T1
算法·c·单链表
珹洺5 小时前
C语言数据结构——详细讲解 双链表
c语言·开发语言·网络·数据结构·c++·算法·leetcode
.Cnn6 小时前
用邻接矩阵实现图的深度优先遍历
c语言·数据结构·算法·深度优先·图论
2401_858286116 小时前
101.【C语言】数据结构之二叉树的堆实现(顺序结构) 下
c语言·开发语言·数据结构·算法·
寻找码源7 小时前
【头歌实训:利用kmp算法求子串在主串中不重叠出现的次数】
c语言·数据结构·算法·字符串·kmp