逐个访问外语字符串的字符

9

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

#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“…”,但这不会改变行为。我想避免使用广泛使用的字符,例如每个字符恰好两个字节。任何建议将不胜感激。

4
这并不是一个基础问题——这种问题与国际化代码非常相关,而且显然也不是易懂的。对于我们在网页浏览器另一端的人来说,问题之一是字符串几乎不可避免地是用UTF-8编码的。然而,如果您使用不同的代码集,例如Cyrillic代码集(如ISO 8859-5),则您在磁盘上的内容可能与我们在网页上看到的内容不同。假设您的磁盘上有UTF-8,最好确保您以十六进制打印“无符号字符”值(显然,在您的计算机上,“普通字符”是带符号的)。 - Jonathan Leffler
可能有相应的库,但我不知道标准C中除了编写自己的UTF-8解析器之外还有其他方法。 - user253751
3个回答

3
我认为中的函数(),(),(),()和()部分相关。例如,您可以使用()函数找出字符串中每个字符所占用的字节数。
另一个很少使用但相关的头文件和函数是和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);
}

setlocale()函数在macOS Sierra 10.12.2上(使用GCC 6.3.0)非常重要,这也是我开发和测试的地方。如果没有它,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前导字节对于BMP(基本多语言平面)中Cyrillic代码块U + 0400 .. U + 04FF的UTF-8编码是正确的。
仅供娱乐价值:BSD的sed拒绝处理输出,因为这些问号代表无效的代码:sed:RE错误:非法字节序列。

2
这里有一个使用sscanf函数的简单解决方案。C99要求printfscanf(以及其他函数)都能理解%s%c字符码的l大小限定符,从而使它们在多字节(即UTF-8)表示形式和宽字符串/字符(即wchar_t,这是一个足够大的整数类型,用于包含代码点)之间进行转换。这意味着你可以使用它来一次拆分一个(多字节)字符的字符串,而不必担心序列只是七位字符(英语)或不是。 (如果听起来很复杂,请查看代码。本质上,它只是将格式字符串添加了一个l限定符。)
这确实使用了wchar_t,在某些平台上(Windows,咳咳)可能会受到限制,最多只有16位。我怀疑如果在Windows上使用星际飞船字符,则可能会得到代理字符,这可能会给您带来麻烦,但该代码在Linux和Mac上都可以正常工作,至少在不太古老的版本中。
请注意程序开头调用setlocale函数。这对任何宽字符函数都是必需的;它将执行区域设置为默认系统区域设置,通常是其中多字节字符为UTF-8的区域设置。(但是,以下代码并不真正关心。它只需要函数的输入是当前语言环境指定的多字节表示形式即可。)
这可能不是这个问题最快的解决方案,但它具有写起来更加简单的优点,至少在我看来。
以下代码基于原始代码,但我将输出重构为一个简单的函数。我还将数字输出更改为十六进制(因为使用代码表进行验证更容易)。
#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;
}

以上代码试图模仿原帖中的代码。我更喜欢使用指针编写循环,而不是索引,并将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
它适用于macOS Sierra 10.12.2与GCC 6.3.0。(使用我的默认编译选项,我添加了一个print3()的声明,并在loc中打印了区域设置信息(否则它是未使用的变量) - 但这是我的超挑剔的默认编译选项在起作用。) - Jonathan Leffler
该问题说:“我想避免使用广泛的字符,因为它们对使用的语言做出了假设。” 你正在使用宽字符,但我不确定它们是否仅限于U+0000..U+07FF(1字节和2字节UTF-8编码范围)。 我可以很容易地相信它们处理BMP(基本多语言计划,又名U+0000..U+FFFF),但是那些超出此范围的字符(如大多数表情符号)如何处理? 作为2个16位的wchar_t值(高代理和低代理)还是它们实际上是32位的值,并将它们作为UTF-32处理? - Jonathan Leffler
顺便提一下,新的表情符号范围是U+1F600至U+1F64F。 - Jonathan Leffler
@jonathan:在 Linux 和我相信 Mac 上,wchar_t 是 32 位。天界代码点已经使用了相当长的时间了(比表情符号早得多)。在 Windows 上,wchar_t 是 16 位,我认为这就是为什么我说这会成为该平台的一个问题的原因。 - rici

1

你被正确地建议编写自己的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
我可以看到UTF8解码器适用于不需要超过2个字节(因此U+0000到U+07FF)的格式正确的UTF8,但它无法处理3字节或4字节的UTF8序列(它会简单地拒绝它们)。对于手头的问题,这是足够的(仅仅)。 - Jonathan Leffler

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接