项目开发中从补码到精度丢失的陷阱

背景

在最近的项目开发中,遇到了许多与二进制相关的问题,比如:

  • 接口返回的 long 型数据精度丢失;
  • 浮点数计算误差(如 0.1 + 0.2 ≠ 0.3);
  • 大整数运算或文件数据转化。

这些问题的本质,都离不开对 计算机底层二进制表示与运算规则 的理解。

基于此,这篇文章将讲解二进制的常见表示与运算法则,并结合项目中的实际案例,一步步剖析这些看似"神秘"的问题。

基础知识

计算机中的数据

在计算机中,所有数据本质上都是由二进制(0 和 1)组成的。大体可以分为:

  • 数值数据:无符号数据、有符号数据等;
  • 非数值数据:字符、图像、音频等。

数值数据

无符号数据

在二进制中,无符号数据通过 0、1 表示。

例如 3 个 bit 能表示 8 个数 (0、1、2、3、4、5、6、7)

有符号数据

而有符号数据,则最高位通过 0、1表示正符号(0 是正数、1是负数)

例如 3 个 bit 能表示 8 个数 (+0、+1、+2、+3、-3、-2、-1、-0)

从反码到补码:计算机如何做减法

在二进制中,做加法非常简单,但"减法"其实是通过 加上补码 实现的。

例如 010 + 001 = 011 即 2 + 1 = 3,可当我需要做减法运算呢?

聪明的同学可能已经到了那就加上一个数的负数即可,例如 2 - 1 = 2 +(-1)= 1;

可当我们尝试进行运算时会发现 010 + 101 = 111(-3)显然答案是错误的。

那需要怎么做呢?

答案是在 二进制中 减去一个数 等于 加上一个数的补码

在了解补码之前,我们需要先了解什么是 反码

反码

反码指的是 正数不变,负数除符号位取反

这时候有同学可能已经发现了规律,如下图所示

当正数的二进制每一位的取反后,就是他对应的负数,这就相当于在二进制中,减去一个数,相当于加上一个二进制数的反码。

但反码中存在的缺点:0 存在着 2 种表示方法(+0、-0),但对于 0 来说,存在一种就可以了,没什么区别。

为了解决反码的缺点,补码就顺势而出了。

补码

正数不变,负数在反码的基础上加 1

通过补码,在表示数量不变的基础上,解决了反码中存在 +0、-0 的冗余表示方法。

例如 010 - 001 => 010 + 111(补码) = 001

进制转化

二进制转十进制

以二进制 111.11 为例子

二进制转十进制的核心是 按位加权求和 ,即每个数位上的数字乘以 2 的对应幂次,然后将所有结果相加。整数部分和小数部分的计算规则略有不同,需分开处理。

1. 整数部分计算(111)

整数部分从右往左,幂次从 0 开始依次递增。

  • 最右侧第 1 位(1): <math xmlns="http://www.w3.org/1998/Math/MathML"> 1 × 2 0 = 1 × 1 = 1 1 × 2⁰ = 1 × 1 = 1 </math>1×20=1×1=1
  • 中间第 2 位(1): <math xmlns="http://www.w3.org/1998/Math/MathML"> 1 × 2 1 = 1 × 2 = 2 1 × 2¹ = 1 × 2 = 2 </math>1×21=1×2=2
  • 最左侧第 3 位(1): <math xmlns="http://www.w3.org/1998/Math/MathML"> 1 × 2 2 = 1 × 4 = 4 1 × 2² = 1 × 4 = 4 </math>1×22=1×4=4
  • 整数部分总和: <math xmlns="http://www.w3.org/1998/Math/MathML"> 1 + 2 + 4 = 7 1 + 2 + 4 = 7 </math>1+2+4= 7
2. 小数部分计算(.11)

小数部分从左往右,幂次从 - 1 开始依次递减。

  • 小数点后第 1 位(1): <math xmlns="http://www.w3.org/1998/Math/MathML"> 1 × 2 − 1 = 1 × 0.5 = 0.5 1 × 2⁻¹ = 1 × 0.5 = 0.5 </math>1×2−1=1×0.5=0.5
  • 小数点后第 2 位(1): <math xmlns="http://www.w3.org/1998/Math/MathML"> 1 × 2 − 2 = 1 × 0.25 = 0.25 1 × 2⁻² = 1 × 0.25 = 0.25 </math>1×2−2=1×0.25=0.25
  • 小数部分总和: <math xmlns="http://www.w3.org/1998/Math/MathML"> 0.5 + 0.25 = 0.75 0.5 + 0.25 = 0.75 </math>0.5+0.25= 0.75
3. 最终结果

将整数部分和小数部分相加,得到最终十进制结果:7 + 0.75 = 7.75

十进制转二进制

整数部分: 除 2 取余,直到商为 0。最先得到的余数是最低位,最后得到的余数是最高位。

小数部分: 乘 2 取整,直到积为 0 或者达到精度要求为止。最先得到的是整数最高位

例如 十进制中的 7.75 可以转化为 二进制的 111.11

整数部分是 111

小数部分是 0.11 因此,十进制中的 7.75 是 二进制中的 111.11

IEEE754 标准

JavaScript 采用 IEEE 754 双精度标准,用 64 位存储一个数字,结构分为三部分:

  • 符号位(1 位) :表示正负,0 为正,1 为负。
  • 指数位(11 位) :决定数值的数量级。
  • 小数有效位(52 位) :决定数值的精度,也称为 "尾数"。

在浮点数的科学计数法表示中

例如 <math xmlns="http://www.w3.org/1998/Math/MathML"> 十进制中的 3.5 = 二进制中的 11.1 = 1.11 ∗ 2 1 十进制中的 3.5 = 二进制中的 11.1 = 1.11 * 2^1 </math>十进制中的3.5=二进制中的11.1=1.11∗21

最大精度

小数有效位是 "隐含了一位前导 1" 的。也就是说,52 位的小数有效位实际能表示 52+1=53 位二进制精度。

因此,它能精确表示的最大整数就是 2⁵³ - 1(因为 53 位二进制数的最大值是 "53 个 1",即 2⁵³ - 1)。如果整数超过这个值,JavaScript 就无法保证精确存储和计算了。

实战案例

讲述完相对枯燥的基础知识后,接下来到了相对有趣的项目实战中遇到的案例进行分析。

案例 1:接口返回的 long 类型被 "改写"

某天, 前端小C和后端小Q 在对接接口的时候, 数据明明返回的是long类型 1981543253761712129, 可浏览器显示的却是 1981543253761712000

但这一切瞒不过刚学过 IEEE754 标准的小 C, 小 C 灵光一闪,因为 JavaScript 中最大整数是 2⁵³ - 1 (即 9007199254740991)

而输入的 1981543253761712129,该数字的二进制位数超过 53 位,尾数位(52 位 + 隐藏位 1 位)无法完整存储所有有效数字,JavaScript 会自动舍入,保留最接近的可表示值,导致末尾的 129 被舍去,使用偏移量补零为 000,最终结果为 1981543253761712000

也可以在控制台输入 1981543253761712129 也会被丢弃转化为 1981543253761712000

案例 2:0.1 + 0.2 !== 0.3

0.1 通过 乘2取整法 转化为 二进制过程中,存在着 无限循环小数

而 0.2 也存在着 无限循环小数

同理 0.3 也是存在着无限循环小数。

因此,在二进制中,0.1 + 0.2 != 0.3

解决方法也有特别多,常见的是通过 toFixed 方法进行精度截取。

案例 3:大整数加法(前端高精度计算)

在支付平台/钱包业务中,比较常见的就是大整数加法了, 常见的解法有双指针解法等等。。

js 复制代码
// 双指针解法
function lagerPhoneNumber(number1, number2) {
    // 确保number1是最小数
    if (number1.length > number2.length) {
      [number1, number2] = [number2, number1];
    }

    let res = "";
    const len1 = number1.length;
    const len2 = number2.length;
    let i = 0; // number1的指针
    let j = 0; // number2的指针
    let povit = 0; // 权重
    while (i < len1) {
      let num =
        Number(number1[len1 - 1 - i]) + Number(number2[len2 - 1 - j]) + povit;

      if (num >= 10) {
        povit = 1;
        num = num - 10;
      } else {
        povit = 0; // 重置
      }

      res = num + res;
      i++;
      j++;
    }

    // 最后考虑number2的情况
    while (j < len2) {
      let num = Number(number2[len2 - 1 - j]) + povit;

      if (num >= 10) {
        povit = 1;
        num = num - 10;
      } else {
        povit = 0; // 重置
      }

      res = num + res;
      j++;
    }

    if (povit) {
      // 最后一种情况
      res = 1 + res;
      povit = 0;
    }

    return res;
  }

最后

二进制、补码、IEEE754 这些概念看似"底层",但在平时项目开发中影响巨大。

理解这些原理,能帮助我们在面对数据精度问题时,不再只是 "试出来",而是 "看得懂、解释清、能修复"。

相关推荐
D_C_tyu3 小时前
Vue3 + Element Plus 实现前端手动分页
javascript·vue.js·elementui
黑云压城After3 小时前
vue2实现图片自定义裁剪功能(uniapp)
java·前端·javascript
芙蓉王真的好14 小时前
NestJS API 提示信息规范:让日志与前端提示保持一致的方法
前端·状态模式
dwedwswd4 小时前
技术速递|从 0 到 1:用 Playwright MCP 搭配 GitHub Copilot 搭建 Web 应用调试环境
前端·github·copilot
2501_938774294 小时前
Leaflet 弹出窗实现:Spring Boot 传递省级旅游口号信息的前端展示逻辑
前端·spring boot·旅游
meichaoWen4 小时前
【CSS】CSS 面试知多少
前端·css
我血条子呢4 小时前
【预览PDF】前端预览pdf
前端·pdf·状态模式
90后的晨仔4 小时前
报错 找不到“node”的类型定义文件。 程序包含该文件是因为: 在 compilerOptions 中指定的类型库 "node" 的入口点 。
前端
90后的晨仔5 小时前
5分钟搭建你的第一个TypeScript项目
前端·typescript