Lua 数值计算与位运算技巧

深入 Lua 的数值模型与位运算:讲清 5.3+ 的 integer/float 双类型、整数溢出的环绕语义、位运算符与标志位/哈希/位域压缩等实用技巧,并给出浮点精度比较、数值格式化与金额计算的工程建议,助你在协议解析等底层场景写出精确代码。

Lua 5.3 之前只有一种数字类型——双精度浮点数(double),所有整数运算都在浮点上完成。这在处理大整数 ID、哈希、位掩码时会出问题:超过 2^53 的整数无法精确表示,位运算要靠 bit32 库在 32 位范围内模拟。Lua 5.3 引入了整数子类型(integer subtype),把 64 位有符号整数作为独立类型,并内置了完整的位运算符。

理解这套数值模型是写好底层代码的前提:网络协议的字段解析、状态机的标志位、哈希函数、数据压缩都依赖精确的整数与位运算。本文从类型语义讲起,逐层深入到实战技巧。不同版本的数字类型差异可参考 https://plumephp.com/lua-version-comparison/。

数字子类型:integer 与 float

Lua 5.3+ 的数字有两个子类型,运行时用 math.type 区分:

print(math.type(1))        -- integer
print(math.type(1.0))      -- float
print(math.type(2^53))     -- float(^ 运算总是返回 float)
print(1 == 1.0)            -- true(值相等)

关键规则:

  • 字面量 1 是 integer,1.0 是 float;
  • 除法 / 永远返回 float,整除 // 返回 integer(或 float,取决于操作数);
  • ^(幂)永远返回 float;
  • 两个 integer 做 + - * 时,溢出会环绕(wrap around)而不是报错或转 float。
print(3 / 2)     -- 1.5    (float)
print(3 // 2)    -- 1      (integer)
print(7 % 3)     -- 1
print(-7 % 3)    -- 2      (Lua 的取模结果符号跟随除数)
print(2^10)      -- 1024.0 (float)

整数与浮点混算时,integer 会被提升为 float。要显式转换用 math.tointeger 与 //:

print(math.tointeger(3.0))   -- 3
print(math.tointeger(3.5))   -- nil  (无法无损转换)
print(3.7 // 1)              -- 3.0  (向负无穷取整)

整数溢出的环绕语义

64 位整数范围是 -2^63 到 2^63 - 1。溢出时按二进制补码环绕:

local max = math.maxinteger
print(max)              -- 9223372036854775807
print(max + 1)          -- -9223372036854775808 (环绕到最小值)
print(max * 2)          -- -2
print(math.mininteger)  -- -9223372036854775808

环绕在多数场景是"特性"而非 bug:哈希计算、CRC、加密算法都依赖模 2^64 的算术。但在计数、金额这类场景要主动防御:

-- 检测加法溢出:结果符号与操作数不一致
local function add_safe(a, b)
    local r = a + b
    if (a >= 0 and b >= 0 and r < 0) or
       (a < 0 and b < 0 and r >= 0) then
        error("整数溢出")
    end
    return r
end

需要注意的是,LuaJIT 在默认配置下没有整数子类型(LuaJIT 2.1 仍是 double + 可选的 64 位整数 FFI 类型),位运算依赖 bit 库且限定 32 位。跨运行时移植时要特别小心这一点。

位运算符

Lua 5.3+ 内置六个位运算符,直接作用于 64 位整数:

运算符含义示例结果
&按位与0xF0 & 0x0F0
|按位或0xF0 | 0x0F255
~按位异或(二元)/ 取反(一元)0xFF ~ 0x0F240
<<左移1 << 416
>>右移(逻辑)256 >> 416
~x按位取反~0-1
print(5 & 3)      -- 1     (0101 & 0011)
print(5 | 3)      -- 7     (0101 | 0011)
print(5 ~ 3)      -- 6     (0101 ^ 0011)
print(~0)         -- -1    (全 1,即补码 -1)
print(1 << 63)    -- -9223372036854775808 (移入符号位)

位移的边界行为要留意:移位量超过 63 时结果按位宽取模;负移位量会向反方向移动。

print(1 << 64)    -- 1     (64 % 64 = 0)
print(1 >> -1)    -- 0     (等价于 1 << 1 后再看符号,实际按无符号处理)

位运算只接受整数,传入 float 会尝试转换为整数,无法转换则报错:

print(3.0 & 1)    -- 1     (3.0 可无损转 3)
-- print(3.5 & 1) -- error: number has no integer representation

如果要把位运算的结果当无符号数解释(如 32 位哈希),需手动处理符号:

local function to_uint32(n)
    return n & 0xFFFFFFFF          -- 保留低 32 位
end
print(to_uint32(-1))              -- 4294967295

这套位运算语义与其他语言(如 C、Go)基本一致,通用技巧可对照 位运算操作指南 。

位运算实战:标志位、哈希、压缩

标志位(bit flags)

用单个整数打包多个布尔状态,省内存也省序列化体积:

local Flag = {
    ACTIVE   = 1 << 0,   -- 0b0001
    VERIFIED = 1 << 1,   -- 0b0010
    ADMIN    = 1 << 2,   -- 0b0100
    BANNED   = 1 << 3,   -- 0b1000
}

local function has(flags, f)  return (flags & f) ~= 0 end
local function set(flags, f)  return flags | f end
local function clear(flags, f) return flags & ~f end
local function toggle(flags, f) return flags ~ f end

local f = Flag.ACTIVE | Flag.VERIFIED
f = set(f, Flag.ADMIN)
print(has(f, Flag.ADMIN))      -- true
print(has(f, Flag.BANNED))     -- false
f = toggle(f, Flag.ACTIVE)
print(has(f, Flag.ACTIVE))     -- false

快速哈希

异或与乘法构成的哈希函数常用于字符串或整数键的散列:

-- FNV-1a 32 位(截断到 32 位模拟)
local function fnv1a(s)
    local h = 2166136261
    for i = 1, #s do
        h = h ~ s:byte(i)
        h = (h * 16777619) & 0xFFFFFFFF
    end
    return h
end
print(fnv1a("hello"))   -- 某个稳定值

位运算版本的哈希比纯算术版本快,且分布更均匀。注意这里用 & 0xFFFFFFFF 把中间结果截到 32 位,模拟 32 位机器的行为。

位域打包

把多个小字段塞进一个整数,是网络协议与存档的常见做法:

-- 打包:版本(4位) | 类型(8位) | 长度(20位)
local function pack_header(ver, kind, len)
    return (ver & 0xF) | ((kind & 0xFF) << 4) | ((len & 0xFFFFF) << 12)
end

local function unpack_header(h)
    local ver  = h & 0xF
    local kind = (h >> 4) & 0xFF
    local len  = (h >> 12) & 0xFFFFF
    return ver, kind, len
end

local h = pack_header(2, 17, 1000)
print(unpack_header(h))   -- 2  17  1000

这种"按位切片"的技巧在协议解析里无处不在,配合前面的字节序讨论能完整实现二进制协议。用位域打包的好处是:一个 32 位整数能装下四个 8 位字段,网络包头部从十几个字节压到四字节,且解析只需移位和掩码,比逐个读字段快得多。代价是可读性下降,字段定义必须写成文档或常量表,不能靠猜。

位运算与表索引

在实现稀疏矩阵或棋盘类数据结构时,可以把二维坐标压成一个整数索引,避免嵌套表:

local WIDTH = 1000
local function idx(x, y)  return y * WIDTH + x end
local function xy(i)      return i % WIDTH, i // WIDTH end

local grid = {}
grid[idx(3, 5)] = "wall"
print(grid[idx(3, 5)])          -- wall
local x, y = xy(idx(3, 5))
print(x, y)                     -- 3  5

这种"坐标压平"把二维表的哈希查找降为一维,内存占用和访问速度都更优,是游戏地图与图像处理的常用手法。

浮点精度与比较

浮点数遵循 IEEE 754 双精度标准,常见陷阱是十进制小数无法精确表示:

print(0.1 + 0.2)              -- 0.30000000000000004
print(0.1 + 0.2 == 0.3)       -- false

浮点比较必须引入容差(epsilon):

local EPS = 1e-9
local function feq(a, b)
    return math.abs(a - b) < EPS
end
print(feq(0.1 + 0.2, 0.3))    -- true

容差大小的选择取决于数值量级:绝对容差适合小数值,相对容差适合大数值。工程上更稳妥的是混合判据:

local function approx_eq(a, b)
    local diff = math.abs(a - b)
    return diff < 1e-9 or diff < 1e-9 * math.max(math.abs(a), math.abs(b))
end

IEEE 754 还有几个特殊值需要显式处理:math.huge(正无穷)、-math.huge(负无穷)、0/0(NaN)。NaN 不等于任何值,包括它自己:

local nan = 0 / 0
print(nan == nan)             -- false
print(nan ~= nan)             -- true   (唯一可靠的 NaN 判断)

浮点累加的顺序会影响结果,大数吃小数(absorb)是经典问题:把 1e16 与 1.0 相加,结果仍是 1e16。需要高精度求和时用 Kahan 补偿求和。浮点精度的完整讨论可参考 浮点精度与 IEEE 754 。

数值格式化与转换

输出与解析数字时,格式化选项直接影响可读性与往返精度:

print(string.format("%d", 42))          -- 42     (整数)
print(string.format("%5.2f", 3.14159))  --  3.14  (宽度5,2位小数)
print(string.format("%x", 255))         -- ff     (十六进制)
print(string.format("%X", 255))         -- FF
print(string.format("%e", 1234.5))      -- 1.234500e+03
print(string.format("%g", 0.00001))     -- 1e-05
print(string.format("%q", 'a"b'))       -- "a\"b" (带转义)

解析字符串到数字:

print(tonumber("42"))        -- 42     (integer)
print(tonumber("3.14"))      -- 3.14   (float)
print(tonumber("0xFF"))      -- 255    (十六进制)
print(tonumber("1e3"))       -- 1000.0
print(tonumber("abc"))       -- nil
print(tonumber("101", 2))    -- 5      (按二进制解析)

tonumber 的第二参数指定进制(2~36),这在解析自定义格式时非常有用。要注意 tonumber("42") 返回 integer 而非 float,与 42.0 的 math.type 不同,做序列化时可能影响输出格式。

金额计算建议用整数分(cents)而非浮点元:

-- 不推荐:浮点金额
local price = 19.99
local total = price * 3            -- 59.97000000000001

-- 推荐:以分为单位
local price_cents = 1999
local total_cents = price_cents * 3   -- 5997,精确
print(string.format("%.2f", total_cents / 100))  -- 59.97

常见问题(FAQ)

Lua 5.3 的整数溢出会报错吗?

不会。整数运算溢出时按二进制补码环绕,与 C 的无符号整数行为类似(但 Lua 的整数是有符号的)。需要检测溢出时手动判断符号变化。

LuaJIT 支持位运算符吗?

LuaJIT 2.1 默认仍是 double 数字模型,不支持 &、|、~ 这些运算符,需 require "bit" 使用 bit.band、bit.bor 等函数,且限定 32 位。若代码要同时跑在标准 Lua 和 LuaJIT 上,建议封装一层兼容层。

为什么 0.1 + 0.2 不等于 0.3?

因为 0.1 和 0.2 在二进制浮点中都是无限循环小数,只能近似存储,相加后的舍入误差让结果变成 0.30000000000000004。这是 IEEE 754 的固有特性,不是 Lua 的 bug。比较浮点要用容差。

位运算能处理无符号 64 位整数吗?

Lua 的整数是有符号 64 位,没有无符号类型。要模拟无符号行为,用掩码保留有效位(如 & 0xFFFFFFFF 模拟 32 位无符号),或用 string.pack("I8", n) 打包后再按需解释。

大整数运算超出 64 位怎么办?

标准 Lua 没有大数类型,超出 64 位会环绕。需要任意精度整数时,用纯 Lua 的 lua-bint,或借助 FFI 调用 GMP。浮点场景下则要考虑精度损失,见前文的浮点讨论。

延伸阅读

  • https://plumephp.com/lua-performance-optimization/

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「lua」更多文章

  1. Lua 时间日期处理与时区
  2. Lua 在嵌入式与 IoT 中的开发实践
  3. Lua 与 WebAssembly 互操作