逐个字符访问外语字符串

Accessing foreign-language string character by character

提问人:Thomas Hedden 提问时间:1/9/2017 最后编辑:Right legThomas Hedden 更新时间:1/9/2017 访问量:1062

问:

我知道这个问题可能非常初级。如果这是显而易见的,请原谅我。 请考虑以下程序:

#include <stdio.h>

int main(void) {
   // this is a string in English
   char * str_1 = "This is a string.";
   // this is a string in Russian
   char * str_2 = "Это строковая константа.";
   // iterator
   int i;
   // print English string as a string
   printf("%s\n", str_1);
   // print English string byte by byte
   for(i = 0; str_1[i] != '\0'; i++) {
      printf(" %c  ",(char) str_1[i]);
   }
   printf("\n");
   // print numerical values of English string byte by byte
   for(i = 0; str_1[i] != '\0'; i++) {
      printf("%03d ",(int) str_1[i]);
   }
   printf("\n");
   // print Russian string as a string
   printf("%s\n", str_2);
   // print Russian string byte by byte
   for(i = 0; str_2[i] != '\0'; i++) {
      printf(" %c  ",(char) str_2[i]);
   }
   printf("\n");
   // print numerical values of Russian string byte by byte
   for(i = 0; str_2[i] != '\0'; i++) {
      printf("%03d ",(int) str_2[i]);
   }
   printf("\n");
   return(0);
}

输出:

This is a string.
 T   h   i   s       i   s       a       s   t   r   i   n   g   .
084 104 105 115 032 105 115 032 097 032 115 116 114 105 110 103 046
Это строковая константа.
 ▒   ▒   ▒   ▒   ▒   ▒       ▒   ▒   ▒   ▒   ▒   ▒   ▒   ▒   ▒   ▒   ▒   ▒   ▒   ▒   ▒   ▒   ▒   ▒       ▒   ▒   ▒   ▒   ▒   ▒   ▒   ▒   ▒   ▒   ▒   ▒   ▒   ▒   ▒   ▒   ▒   ▒   .
-48 -83 -47 -126 -48 -66 032 -47 -127 -47 -126 -47 -128 -48 -66 -48 -70 -48 -66 -48 -78 -48 -80 -47 -113 032 -48 -70 -48 -66 -48 -67 -47 -127 -47 -126 -48 -80 -48 -67 -47 -126 -48 -80 046

可以看出,英语 (ASCII) 字符串可以打印为字符串或使用数组索引访问并逐个字符(逐个字节)打印,但俄语字符串(我相信编码为 UTF-8)可以打印为字符串,但不能逐个字符访问。

我知道原因是在这种情况下,俄语字符使用两个字节而不是一个字节进行编码。

我想知道的是,是否有任何简单的方法可以使用标准 C 库函数通过正确声明数据类型或以某种方式标记字符串或设置语言环境或其他方式,逐个字符(在本例中为两个字节)打印 Unicode 字符串。

我尝试在俄语字符串前面加上“u8”,即 ,但这并没有改变行为。我想避免使用宽字符来假设正在使用的语言,例如每个字符正好两个字节。任何建议将不胜感激。char * str_2 = u8"..."

c 字符串 unicode-string

评论

4赞 Jonathan Leffler 1/9/2017
这没什么基本的东西——这类问题与国际化代码非常相关,而且并不明显。在 Web 浏览器的另一端,我们面临的一个问题是,字符串几乎是不可避免的,即用 UTF-8 编码。但是,如果您使用的是其他代码集(例如,ISO 8859-5 等西里尔字母代码集),则磁盘上的内容可能与我们在 Web 上看到的内容不同。假设磁盘上确实有 UTF-8,最好确保以十六进制打印“无符号字符”值(显然,plain 在您的机器上已签名)。char
0赞 user253751 1/9/2017
可能有一个库,但我不知道标准 C 的方法(除了编写自己的 UTF-8 解析器)。

答:

3赞 Jonathan Leffler 1/9/2017 #1

我认为 、 、 和 函数 from 是部分相关的。例如,您可以使用 找出字符串中每个字符由多少个字节组成。mblen()mbtowc()wctomb()mbstowcs()wcstombs()<stdlib.h>mblen()

另一个很少使用的标头和函数是 和 。<locale.h>setlocale()

下面是对代码的改编:

#include <assert.h>
#include <locale.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

static inline void ntbs_hex_dump(const char *pc_ntbs)
{
    unsigned char *ntbs = (unsigned char *)pc_ntbs;
    for (int i = 0; ntbs[i] != '\0'; i++)
        printf(" %.2X ", ntbs[i]);
    putchar('\n');
}

static inline void ntbs_chr_dump(const char *pc_ntbs)
{
    unsigned char *ntbs = (unsigned char *)pc_ntbs;
    for (int i = 0; ntbs[i] != '\0'; i++)
        printf(" %c  ", ntbs[i]);
    putchar('\n');
}

int main(void)
{
    char *loc = setlocale(LC_ALL, "");
    printf("Locale: %s\n", loc);

    char *str_1 = "This is a string.";
    char *str_2 = "Это строковая константа.";

    printf("English:\n");
    printf("%s\n", str_1);
    ntbs_chr_dump(str_1);
    ntbs_hex_dump(str_1);

    printf("Russian:\n");
    printf("%s\n", str_2);
    ntbs_chr_dump(str_2);
    ntbs_hex_dump(str_2);

    char *mbp = str_2;
    while (*mbp != '\0')
    {
        enum { MBS_LEN = 10 };
        int mbl = mblen(mbp, strlen(mbp));
        char mbs[MBS_LEN];
        assert(mbl < MBS_LEN - 1 && mbl > 0);
        // printf("mbl = %d\n", mbl);
        memmove(mbs, mbp, mbl);
        mbs[mbl] = '\0';
        printf(" %s ", mbs);
        mbp += mbl;
    }
    putchar('\n');

    return(0);
}

这很重要,至少在macOS Sierra 10.12.2(带有GCC 6.3.0)上是这样,这是我开发和测试它的地方。没有它,总是返回 ,并且代码中没有任何好处。setlocale()mblen()1

我从中得到的输出是:

Locale: en_US.UTF-8
English:
This is a string.
 T   h   i   s       i   s       a       s   t   r   i   n   g   .  
 54  68  69  73  20  69  73  20  61  20  73  74  72  69  6E  67  2E 
Russian:
Это строковая константа.
 ?   ?   ?   ?   ?   ?       ?   ?   ?   ?   ?   ?   ?   ?   ?   ?   ?   ?   ?   ?   ?   ?   ?   ?       ?   ?   ?   ?   ?   ?   ?   ?   ?   ?   ?   ?   ?   ?   ?   ?   ?   ?   .  
 D0  AD  D1  82  D0  BE  20  D1  81  D1  82  D1  80  D0  BE  D0  BA  D0  BE  D0  B2  D0  B0  D1  8F  20  D0  BA  D0  BE  D0  BD  D1  81  D1  82  D0  B0  D0  BD  D1  82  D0  B0  2E 
 Э  т  о     с  т  р  о  к  о  в  а  я     к  о  н  с  т  а  н  т  а  . 

再加一点努力,代码就可以将 UTF-8 数据的字节对更紧密地打印在一起。D0 和 D1 前导字节对于西里尔文代码块 U+0400 的 UTF-8 编码是正确的。BMP(基本多语言平面)中的 U+04FF。

只是为了您的娱乐价值:BSD 拒绝处理输出,因为这些问号代表无效代码:.sedsed: RE error: illegal byte sequence

1赞 DYZ 1/9/2017 #2

我们正确地建议您编写自己的 UTF-8 解析器,这实际上很容易做到。下面是一个示例实现:

int utf8decode(unsigned char *utf8, unsigned *code) {
  while(*utf8) { /* Scan the whole string */
    if ((utf8[0] & 128) == 0) { /* Handle single-byte characters */
      *code = utf8[0];
      utf8++;
    } else { /* Looks like it's a 2-byte character; is it? */
      if ((utf8[0] >> 5) != 6 || (utf8[1] >> 6) != 2)
        return 1;
      /* Yes, it is; do bit magic */
      *code = ((utf8[0] & 31) << 6) + (utf8[1] & 63);
      utf8 += 2;
    }
    code++;
  }
  *code = 0;
  return 0; /* We got it! */
}

让我们做一些测试:

int main(void) {
  int i = 0;
  unsigned char *str = "Это строковая константа.";
  unsigned codes[1024]; /* Hope it's long enough */ 

  if (utf8decode(str, codes) == 1) /* Decode */
    return 1;
  while(codes[i]) /* Print the result */
    printf("%u ", codes[i++]); 

  puts(""); /* Final newline */
  return 0;
}

1069 1090 1086 32 1089 1090 1088 1086 1082 1086 1074 1072 1103 32 1082 1086 1085 1089 1090 1072 1085 1090 1072 46

评论

2赞 Jonathan Leffler 1/9/2017
我可以看到 UTF8 解码器适用于不需要超过 2 个字节的格式良好的 UTF8(因此 U+0000 到 U+07FF),但它不适用于 3 字节或 4 字节的 UTF8 序列(它只是拒绝它们)。对于手头的问题,这就足够了(只是)。
2赞 rici 1/9/2017 #3

这是使用该函数的简单解决方案。C99 要求 and(和朋友)都理解 and 字符代码的大小限定符,导致它们在多字节(即 UTF-8)表示和宽字符串/字符(即 ,这是一个足够大的整数类型,可以包含代码点)之间进行转换。这意味着您可以使用它一次将一个字符串分开一个(多字节)字符,而不必担心序列是否只是七位字符(英语)。从本质上讲,它只是向格式字符串添加一个限定符。sscanfprintfscanfl%s%cwchar_tl

这确实使用 ,在某些平台(Windows、咳嗽、咳嗽)上可能限制为 16 位。我怀疑如果你在 Windows 上使用星体平面字符,你最终会得到代理字符,这可能会让你感到悲伤,但代码在 Linux 和 Mac 上都能正常工作,至少在不太古老的版本中是这样。wchar_t

请注意程序开头的调用。这对于任何宽字符函数的工作都是必需的;它将执行区域设置设置为默认系统区域设置,该区域设置通常是多字节字符为 UTF-8 的区域设置。(但是,下面的代码并不在乎。它只要求函数的输入采用当前区域设置指定的多字节表示形式。setlocale

它可能不是解决这个问题的最快解决方案,但它的优点是编写起来要简单得多,至少在我看来是这样。

以下内容基于原始代码,但为了简单起见,我将输出重构为单个函数。我还将数字输出更改为十六进制(因为它更容易使用代码图表进行验证)。

#include <locale.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <wchar.h>

/* Print the string three ways */
void print3(const char* s);

void print3(const char* s) {
   wchar_t wch;
   int n;
   // print as a string
   printf("%s\n", s);
   // print char by char
   for (int i = 0; s[i] != '\0'; i += n) {
      sscanf(s+i, "%lc%n", &wch, &n);
      printf("   %lc   ", wch);
   }
   putchar('\n');
   // print numerical values char by char 
   for (int i = 0; s[i] != '\0'; i += n) {
      sscanf(s+i, "%lc%n", &wch, &n);
      printf(" %05lx ", (unsigned long)wch);
   }
   putchar('\n');
}

int main(void) {
   setlocale(LC_ALL, "");

   char *str_1 = "This is a string.";
   char *str_2 = "Это строковая константа.";
   char *str_3 = u8"\U0001d7d8\U0001d7d9\U0001f638 in the astral plane";

   print3(str_1);
   print3(str_2);
   print3(str_3);
   return 0;
}

以上尝试模仿 OP 中的代码。我实际上更愿意使用指针而不是索引来编写循环,并检查作为终止条件的返回代码:sscanf

/* Print the string three ways */
void print3(const char* s) {
   wchar_t wch;
   int n;
   // print as a string
   printf("%s\n", s);
   // print char by char
   for (const char* p = s;
        sscanf(p, "%lc%n", &wch, &n) > 0;
        p += n) {
           printf("   %lc   ", wch);
   }
   putchar('\n');
   for (const char* p = s;
        sscanf(p, "%lc%n", &wch, &n) > 0;
        p += n) {
           printf(" %5.4lx ", (unsigned long)wch);
   }
   putchar('\n');
}

更好的做法是确保没有返回错误,表明存在无效的多字节序列。sscanf

这是我系统上的输出:

This is a string.
   T      h      i      s             i      s             a             s      t      r      i      n      g      .   
  0054   0068   0069   0073   0020   0069   0073   0020   0061   0020   0073   0074   0072   0069   006e   0067   002e 
Это строковая константа.
   Э      т      о             с      т      р      о      к      о      в      а      я             к      о      н      с      т      а      н      т      а      .   
  042d   0442   043e   0020   0441   0442   0440   043e   043a   043e   0432   0430   044f   0020   043a   043e   043d   0441   0442   0430   043d   0442   0430   002e 
𝟘𝟙😸 in the astral plane
   𝟘      𝟙      😸             i      n             t      h      e             a      s      t      r      a      l             p      l      a      n      e   
 1d7d8  1d7d9  1f638   0020   0069   006e   0020   0074   0068   0065   0020   0061   0073   0074   0072   0061   006c   0020   0070   006c   0061   006e   0065

评论

1赞 Jonathan Leffler 1/9/2017
它适用于带有 GCC 10.12.2 的 macOS Sierra 6.3.0。(使用我的默认编译选项,我添加了一个声明并打印了语言环境信息(否则它是一个未使用的变量)——但这是我超级挑剔的默认编译选项。print3()loc
0赞 Jonathan Leffler 1/9/2017
这个问题确实说“我想避免使用对所使用的语言做出假设的宽字符”。您使用的是宽字符,但我不确定它们是否仅限于 U+0000 ..U+07FF(1 字节和 2 字节 UTF-8 编码范围)。我很容易相信他们处理 BMP(基本的多语言计划,又名 U+0000 ..U+FFFF),但是如何处理该范围之外的字符(例如大多数表情符号)?作为 2 个 16 位值(高代理项和低代理项),或者它们实际上是 32 位值并将它们作为 UTF-32 处理?wchar_t
0赞 Jonathan Leffler 1/9/2017
顺便说一句,新的表情符号在 U+1F600 范围内。U+1F64F。
0赞 rici 1/9/2017
@jonathan:在 Linux 上,我相信 Mac 上,wchar_t是 32 位。星体平面代码点已经使用了很长时间,现在(远远早于表情符号)。在 Windows 上,我认为 wchar_t 是 16 位,这就是为什么我说这在该平台上会是一个问题。