在 Windows 下用 LuaJIT 时,会遇到非常棘手的编码问题,有下列解决方法(以下内容只针对简体中文版 Windows,其他版本的也类似)。

源文件不包含非 ASCII 字符

好处

编码问题基本不复存在。

坏处

  1. 功能不全,代价太高。
  2. 之后功能无法满足时,基本只能改用 GBK 或者 GB1830 编码,不然迁移成本较高(比如调用 Windows API 的地方不能无缝迁移,之前用的是 A 系列,无论是转码继续用还是转码换 W 系列,都得改代码)。

源文件使用 GBK 编码

好处

GBK 是系统默认的 ANSI 编码,使用方便,可以直接调用内置库、Windows 的 A 系列 API 和系统相关的第三方库(比如 LuaFileSystem)。

坏处

  1. GBK 编码能表示的字符范围很有限,比如无法表示韩文、emoji 符号、罕见汉字和很多特殊符号。一但需要处理这些字符,就很麻烦,比如用其他编码把内容写到单独的数据文件(这又涉及文件编码不统一的问题)。
  2. 系统无关的第三方库通常使用 UTF-8 编码,经常用的话可能需要频繁转码。
  3. 读写 UTF-8 编码的文件时可能需要转码(有些场景需要,有些场景不需要),既麻烦又影响性能,不注意的话还会造成文件损坏。
  4. 用 GBK 编码的源文件对很多命令行工具不友好,主要是 git、grep、sed 等来自 Linux 的工具,配合使用非常麻烦。
  5. 源文件跨平台使用会出问题,被当成 UTF-8 文件,转码的话需要维护不同编码的源文件,成本较高。

源文件使用 GB18030 编码

好处

GB18030 基本上是 GBK 的超集(所以基本共享了 GBK 的好处),但表达力强很多,比如可以正常表示韩语、emoji 字符、罕见汉字和很多特殊符号,基本克服了 GBK 的坏处 1。

坏处

  1. 因为系统的 ANSI 编码是 GBK 而不是 GB18030,所以在窗体内容显示、路径名等地方会出兼容性问题(显示问号、无法创建文件等等,不影响文件内容的读写),需要注意。
  2. 同 GBK 坏处的 2 到 5。

源文件使用 UTF-8 编码,自己转编码

好处

使用系统无关的第三方库很方便,源文件对命令行工具也更友好,如果习惯使用 UTF-8 文本文件的话,读写文件无需转码。不存在 GBK 坏处的 2 到 5。

坏处

调用内置库、系统相关的第三方库、Windows 的 A 版本(一般不需要)和 W 版本 API 都需要转码,比较麻烦。

修改 Lua 自身代码来兼容 UTF-8

lua-unicode

好处

  1. 在覆盖到的函数中直接使用 UTF-8 编码即可,不需要频繁转码。
  2. 不依赖第三方库,用起来比较方便。

坏处

  1. 维护上比较麻烦(不同版本的 Lua 修改方法不同,需要分别适配),覆盖的函数很有限(自己适配也比较麻烦),也覆盖不到第三方库。
  2. 会产生额外的性能开销(比如能确保是 ASCII 编码的情况也必须转码)。
  3. 只涉及少数几个内置库的函数,解决的问题很有限。调用系统相关第三方库和 Windows API 时依然需要转码。
  4. 源文件在别人的环境可能无法正常运行,自己也可能用不了某些第三方库,共享和合作方面会遇到麻烦。

使用适配了 UTF-8 的第三方库

luawinfileluapower/fs

好处

  1. 使用覆盖到的函数时不需要频繁转码,也不用维护定制版的 Lua。
  2. 不会产生额外的性能开销,可以根据实际情况选择内置函数或者第三方库的函数。
  3. 没有上一个方法中共享和合作相关麻烦。

坏处

  1. 适配 UTF-8 的第三方库覆盖并不全面,是否有人维护也是问题,自己维护的话成本较高。
  2. 用其他库或者 Windows API 时依然需要转码,解决的问题也有限。
  3. 用这些库会导致代码不能跨平台迁移(或者自己适配,涉及函数较多,比较麻烦)。

我的最终方案

源码使用 UTF-8 编码,调用函数时根据实际情况转码。我用的是 LuaJIT,可以通过 ffi 调用系统的转码函数。

原因

因为非 UTF-8 的源文件带来的种种麻烦是我非常反感的,而且在读写文件时转码比较影响性能,不像转换个短小的路径名或者用来显示的字符串(而且这些场景一般也不用考虑性能)。另外 Double Commander 的 Lua 源文件需要用 UTF-8 编码,维护不同编码的源文件也很麻烦。

好处

  1. 使用 UTF-8 编码的源文件,可以避免很多麻烦(如 GBK 坏处的 2 到 5)。
  2. 不需要安装第三方库,不需要搭建 Windows 下比较麻烦的编译环境,只需要一个 .lua 文件。
  3. 使用起来比较方便,在文件中加三行代码,然后直接在字符串之前加 A 或者 L 即可,可以全面覆盖内置库、第三方库和 Windows API 等场景。
  4. 不用修改 Lua 解释器,以及处理由此而来的各种麻烦。
  5. 性能开销小,基本无需考虑。

坏处

  1. 需要携带一个 .lua 文件并且添加几行代码。
  2. 字符串前加 A 或者 L 虽然容易,但也是额外的工作,跨平台迁移时也需要适配(工作量很小)。

实现方法

utf8fix.lua(修改自 actboy168/ydhost):

local ffi = require 'ffi'

ffi.cdef[[
int MultiByteToWideChar(unsigned int CodePage,
    unsigned long dwFlags,
    const char* lpMultiByteStr,
    int cbMultiByte,
    wchar_t* lpWideCharStr,
    int cchWideChar);

int WideCharToMultiByte(unsigned int CodePage,
    unsigned long dwFlags,
    const wchar_t* lpWideCharStr,
    int cchWideChar,
    char* lpMultiByteStr,
    int cchMultiByte,
    const char* lpDefaultChar,
    int* pfUsedDefaultChar);
]]

local CP_UTF8 = 65001
local CP_ACP = 0

-- UTF-8 to UTF-16
local function L(input)
    local wlen = ffi.C.MultiByteToWideChar(CP_UTF8, 0, input, #input, nil, 0)
    local wstr = ffi.new('wchar_t[?]', wlen + 1)
    ffi.C.MultiByteToWideChar(CP_UTF8, 0, input, #input, wstr, wlen)

    return wstr
end

-- UTF-8 to ANSI
local function A(input)
    local wlen = ffi.C.MultiByteToWideChar(CP_UTF8, 0, input, #input, nil, 0)
    local wstr = ffi.new('wchar_t[?]', wlen + 1)
    ffi.C.MultiByteToWideChar(CP_UTF8, 0, input, #input, wstr, wlen)

    local len = ffi.C.WideCharToMultiByte(CP_ACP, 0, wstr, wlen, nil, 0, nil, nil)
    local str = ffi.new('char[?]', len + 1)
    ffi.C.WideCharToMultiByte(CP_ACP, 0, wstr, wlen, str, len, nil, nil)

    return ffi.string(str)
end

local function Pass(input)
    return input
end

return {
    L = L,
    A = A,
    Pass = Pass,
}

使用方法:

local utf8fix = require 'utf8fix'
local L = utf8fix.L
local A = utf8fix.A

local file = io.open(A'测试.txt', 'wb')
if file == nil then
    print('failed to open file')
else
    -- UTF-8 编码,设置 chcp 65001 后可以正常显示
    print('打开文件成功')

    -- ANSI 编码
    print(A'打开文件成功')
end

-- 写入文件时不需要转码
file:write('test 测试鿃㒨にほんご조선말???✨');
file:close()

local ffi = require 'ffi'

ffi.cdef[[
int MessageBoxW(void *w, const wchar_t *txt, const wchar_t *cap, int type);
int MessageBoxA(void *w, const char *txt, const char *cap, int type);
]]

-- 可以正常显示
ffi.C.MessageBoxW(nil, L'test 测试鿃㒨にほんご조선말???✨', L'W 测试?', 0)

-- 部分中文、日文可以正常显示,其他字符显示为问号,只为演示,基本没有实用性
ffi.C.MessageBoxA(nil, A'test 测试鿃㒨にほんご조선말???✨', A'A 测试?', 0)

这里的 A() 转码了两次(UTF-8 -> UTF-16,UTF-16 -> ANSI),代价稍高。但一般只用来转换短小的文件路径,所以还好。如果使用其他编码转换库,可以一步完成。

如果使用的不是 LuaJIT,可以封装其他的编码转换库(比如 perfgao/lua-resty-unicode,或者用网上 Lua 实现的版本。

如果需要跨平台,按需把 A() 替换成 Pass() 即可(不需要处理很多函数)。L() 也一样,但用 L() 的代码基本就告别跨平台了,所以基本没必要处理。

后续

发现 luapower 上有不少使用 UTF-8 编码功能又强大的库,fs 和 proc 基本可以替代 io、os 内置库和 LuaFileSystem 涉及编码问题的函数,除了 io.popen 和 LuaFileSystem 的 lock 系列函数。这样基本就不会遇到编码问题了,功能也得到了很大的增强。