使用环境:Windows、Visual Studio 2022 Community、MSVC 对应的 SFML 3.0.0 DLL。

sf::String 内部使用 UTF-32,但这不代表传入任何编码的字符串都能自动识别。中文出现问题时,需要区分输入字节的编码、调用的转换接口,以及输出端的编码要求。

本文针对 SFML 3.0.0,结合该版本文档、源码及 Windows 下的实际测试说明这些接口的行为;测试环境和结果见文末。

sf::String 和 std::string 分别存什么

sf::String 的内部成员是 std::u32string,以 32 位代码单元存储 Unicode 文本。对于有效的 UTF-32 文本,一个代码单元对应一个 Unicode 码点;这不等于一个视觉上的字符,例如组合字符可能包含多个码点。

std::string 则是 char 序列,本身没有固定编码。它可以保存 UTF-8、GBK 或其他字节数据。sf::String 和 std::string 可以一起使用,关键是明确编码,并显式转换。

例如,UTF-8 编码的“你好”占 6 个字节,正确解码后的 sf::String 包含 2 个码点。

普通窄字符串构造函数不会自动解码 UTF-8

以下写法调用的是 SFML 所称的 ANSI 字符串构造函数:

sf::String text = "你好";

这里至少有两层问题:

  1. 源文件和编译器设置决定普通字符串字面量实际包含什么字节。
  2. sf::String(const char*, locale) 如何解释这些字节。

MSVC 的 /utf-8 可以将源字符集和执行字符集设为 UTF-8,但它不会把这个构造函数变成 UTF-8 解码器。因此,保存源文件为 UTF-8、开启 /utf-8,还不足以使上述写法正确。

对于已知为 UTF-8 的 std::string,应显式调用 fromUtf8():

#include <SFML/System/String.hpp>
#include <string>

int main()
{
    // UTF-8 字节:E4 BD A0 E5 A5 BD,即“你好”。
    const std::string input = "\xE4\xBD\xA0\xE5\xA5\xBD";
    const sf::String text = sf::String::fromUtf8(input.begin(), input.end());
    return text == sf::String(U"\u4F60\u597D") ? 0 : 1;
}

也可以使用 u8"你好":C++17 中它是 char 字符串,C++20 中它是 char8_t 字符串。无论哪一种,都应把相应范围交给 fromUtf8(),而不是假定普通窄字符串构造函数会识别 UTF-8。

为什么设置 locale 仍然不够

给 toAnsiString() 或窄字符串构造函数设置中文 locale,并不能解决 SFML 3.0.0 的多字节中文转换问题。

该版本的实现使用 std::ctype<wchar_t>:输入时逐个 char 调用 widen(),输出时逐个码点调用 narrow(),每次输出一个 char。它没有按完整的多字节序列解码,也不能为一个码点输出多个字节。

所以,虽然 GBK 和 UTF-8 都能表示中文,这些 SFML ANSI 接口并不是通用的 GBK 或 UTF-8 转换接口。换成 std::locale("chs") 也不能补上多字节处理能力。locale 对支持的单字节映射仍可能有作用,但不能作为这里的中文解决方案。

另一个容易忽略的细节是转换失败后的内容。文档概括为无法表示的字符被丢弃,但 SFML 3.0.0 的 ANSI 输出路径会使用默认替代值 \0。本次测试中:

const sf::String text = U"\u4F60\u597D";
const std::string bytes = text.toAnsiString(std::locale::classic());
// 实测 bytes.size() == 2,两个字节都是 '\0'。

这会造成字符串“看起来为空”,尤其是在按零结尾字符串处理时。不能仅凭控制台没有显示中文,就判断字符被删除,或者判断 std::string 不能保存中文。

如果已有 GBK 数据,应使用支持该编码的转换工具先解码;Windows 上可使用 MultiByteToWideChar 指定代码页 936。得到 UTF-16 后,再通过 fromUtf16() 交给 SFML。不要把 GBK 数据交给 fromUtf8()。

宽字符串能解决常见中文,但有版本限制

L"你好" 和 U"你好" 对常见中文可以工作,但不能据此断言所有 Unicode 字符都能通过宽字符串接口正确转换。

Windows 下 wchar_t 通常是 16 位;UTF-16 用代理对表示 U+FFFF 以外的码点。SFML 3.0.0 的宽字符串输入逐个复制 wchar_t,不合并代理对;其宽字符串输出也不会将这类码点编码为代理对。

例如,在本次 Windows 测试中,L"\U0001F600"(😀)构造出的 sf::String 包含两个代理项,而不是一个 U+1F600 码点;sf::String(U"\U0001F600").toWideString() 则得到空字符串。

如果输入是 UTF-16,应使用 fromUtf16();需要 UTF-16 输出时应使用 toUtf16()。跨平台交换文本时,UTF-8 通常更方便。

接口或写法 SFML 3.0.0 下应注意的条件
sf::String("你好") 走 ANSI 路径,不自动解码 UTF-8 或 GBK
sf::String::fromUtf8(begin, end) 用于 UTF-8 输入;需保证输入编码正确
sf::String(L"你好") Windows 下常见中文可用;宽字符串代理对有上述限制
sf::String(U"你好") 明确的 UTF-32 字面量,不依赖窄字符串 locale
toAnsiString(locale) 不是通用的多字节编码输出接口,可能产生替代字节
toWideString() Windows 下无法通过此版本接口完整输出补充平面字符
toUtf8() / toUtf16() / toUtf32() 明确指定输出编码;前提是内部文本本身正确

把 sf::String 写成 UTF-8 文件

SFML 3.0.0 的 toUtf8() 返回 sf::U8String,不是 std::string,也不是 C++20 的 std::u8string。可以把其中的字节明确写入普通输出流:

#include <SFML/System/String.hpp>
#include <fstream>
#include <ostream>

std::ostream& writeUtf8(std::ostream& out, const sf::String& text)
{
    const auto bytes = text.toUtf8();
    return out.write(reinterpret_cast<const char*>(bytes.data()),
                     static_cast<std::streamsize>(bytes.size()));
}

int main()
{
    const sf::String text = U"Hello\u4F60\u597D\U0001F600";
    std::ofstream file("example.txt", std::ios::binary);
    if (!file)
        return 1;

    writeUtf8(file, text);
    file.close();
    return file ? 0 : 1;
}

这个例子输出 UTF-8 字节,不依赖命名 locale,也不会添加 BOM。它适合写入约定为 UTF-8 的文件;读取方也需要按 UTF-8 解码。

如果喜欢流运算符,也可以在自己的代码中让 operator<< 调用 writeUtf8()。需要明确约定:该运算符输出的是 UTF-8 字节,而不是由流自动选择一种编码。

控制台显示是另一个问题

正确的 UTF-8 文件内容,不代表同样的字节在所有控制台上都会正确显示。输出端的编码设置和字体也需要匹配。

wcout.imbue(std::locale("chs")) 不能作为通用的 Windows 中文输出方案:命名 locale 的可用性依赖标准库,宽流的处理还涉及运行库与终端。同样,给 std::wofstream 设置中文 locale,也不能保证输出 UTF-8。

本次 MSVC 标准库能创建测试中的中文 locale,而本次 MinGW/libstdc++ 无法创建 "chs"、".936" 和 ".UTF-8",会抛出异常。这是所测环境的结果,不能推广到所有 g++ 环境。

在 Windows 控制台输出 UTF-8 窄字符串时,可按实际终端需要设置 UTF-8 输出代码页,例如运行 chcp 65001,或调用 SetConsoleOutputCP(CP_UTF8)。程序输出被重定向到文件时,则应关注写出的字节是否正确。编译器字符集、SFML 转换函数、流处理和控制台代码页是不同环节。

本次验证

使用 Windows x64、SFML 3.0.0 源码,分别构建与编译器匹配的动态库进行验证:

  • GCC/MinGW-w64 15.2.0,C++17。
  • Visual Studio 2022 Community 17.14.24,MSVC 19.44.35222,C++17,/utf-8。

验证检查的是码点、字节与字符串长度,避免把终端显示效果当成编码正确性的证据。上述 UTF-8/UTF-16 往返、常见中文宽字符串、ANSI 空字节及 Windows 宽字符串代理对问题均有对应测试。本文结论限定于所核对的 SFML 3.0.0 实现与上述测试条件。

参考资料