☰
Python数据存储与运算机制详解:变量、浮点精度与位运算
2026/10/8 19:34:17 网站建设 项目流程

我决定从安装完Python、打开编辑器敲下第一行代码那天说起。

当时我给自己定的计划很简单:每天记一点笔记,把数据存储和运算这两个最基础的底座吃透。结果一学才发现,这两块内容看着不难,水却深得很——a = 1这行代码背后发生了什么?0.1 + 0.2为什么不是0.3?列表切片为什么能倒着取?位运算到底有什么用?这些问题每一个拎出来都能写一篇笔记。折腾了半个多月,踩了无数坑,终于把这块知识体系理清了。这篇就把我的自学笔记整理出来,重点围绕数据存储、变量机制、数值精度、运算符优先级和位运算这些核心内容展开,适合刚入门想系统梳理基础的人,也适合学了一阵子但总觉得哪里囫囵吞枣的朋友。保证都是实操验证过的东西,不整虚的。

1. 先搞清楚:变量到底是怎么“存数据”的

1.1 变量是标签,不是盒子

绝大多数Python教程开篇就会教你写a = 1,然后告诉你“把整数1赋值给变量a”。问题就出在这个“赋值”上——很多人会把变量理解成一个盒子,把1装进去。这个理解在Java、C这种语言里勉强说得通,但在Python里是错的。

Python里的变量本质上是“贴在对象上的标签”。当你写a = 1时,实际发生的事情是:先在内存里创建一个整数对象1,然后把标签a贴到这个对象上。如果再写b = a,并不是把对象复制一份给b,而是把同一个对象的另一个标签b也贴上去。所以此时a和b指向的是同一个内存对象。

我用一个直观的方式验证过:

a = 1 b = a print(id(a), id(b)) # 两个id完全一致 a = 2 print(b) # 依然是1

这里很多人会困惑:既然a和b指向同一个对象,为什么改了a,b不变?关键就在于a = 2这行代码——它不是“修改了原来那个1”,而是“创建了一个新的整数对象2,并把标签a从1上撕下来贴到2上”。原来的1还在内存里,只是没人贴它了,标签b还挂在它上面。

这个理解为什么重要?因为后续学变量作用域、参数传递、可变对象修改时,如果还用“盒子模型”思考,百分之百要栽跟头。我在学函数传参时就深有体会,后面我还会详细讲这个坑。

1.2 小整数缓存与“引用”导致的连环坑

Python有个机制叫“小整数缓存”:-5到256之间的整数,在解释器启动时会预先创建好,所有代码引用这些数字时都指向同一个对象。所以你会发现:

a = 100 b = 100 print(id(a) == id(b)) # True,指向同一个缓存对象 c = 300 d = 300 print(id(c) == id(d)) # False,超过缓存范围就各自创建对象

这个细节看起来冷门,但直接在“变量值相同但id不同”这类问题里会碰见。还有更实际的坑——可变对象的引用共享。刚入门时我用列表做过一个矩阵初始化:

matrix = [[0] * 3] * 3 print(matrix) # [[0, 0, 0], [0, 0, 0], [0, 0, 0]] matrix[0][0] = 5 print(matrix) # [[5, 0, 0], [5, 0, 0], [5, 0, 0]]

当时我直接懵了,改一个位置全列都变。原因就是[0] * 3先创建了一个长度为3的列表对象,外层* 3复制的是这个列表对象的引用,而不是内容。内存里只有一份[0, 0, 0],外面三个位置全是指向它的指针。正确做法是:

matrix = [[0] * 3 for _ in range(3)]

这个错误在力扣刷题、构建邻接矩阵时特别常见,我的建议是:涉及嵌套列表构造,一律用列表推导式,不要用乘法。

2. 数据存储的底层细节:整数、浮点数、字符串到底是什么

2.1 整数是对象,浮点数有精度陷阱

Python的整数是任意精度对象,理论上可以无限大——只要内存够。这和C语言的int是完全不同的概念,所以Python里做超大数运算不用担心溢出:

print(2 ** 100)

一张图解释就是,Python的整数在内存里是根据实际位数分配空间的,存多少位就占多少字节。但这个特点也意味着整数运算比C语言慢,做大规模数值计算时(比如量化交易回测、矩阵乘法),纯Python循环会非常吃力,这就是后面我要引入numpy的核心理由。

浮点数就更坑了。0.1 + 0.2的结果是0.30000000000000004,我相信每个自学者都会遇到。原因在于:计算机用二进制表示浮点数,而0.1和0.2的二进制是无限循环小数,必须截断存储。这不是Python的问题——Python遵循的是IEEE 754浮点标准,Java、C++全都一样,谁也不能精确表示0.1。

我在实际操作中试过几种方案:

# 方案一:round圆整,注意第二个参数是保留几位小数 print(round(0.1 + 0.2, 2)) # 0.3 # 方案二:Decimal精确十进制 from decimal import Decimal print(Decimal('0.1') + Decimal('0.2')) # 0.3 # 方案三:比较时用误差范围 a = 0.1 + 0.2 print(abs(a - 0.3) < 1e-9) # True

金融计算、涉及钱的场景,优先用Decimal,但注意要传字符串,直接传0.1这种浮点字面量进去,Decimal接收的其实已经是失真后的二进制值。

2.2 字符串和字节串:文本在内存里的模样

字符串在Python里是Unicode序列,每个字符对应一个码点。存储上,Python为了兼顾性能和内存做了内部优化——像ASCII可表示的字符串,在内部会用紧凑的单字节表示;只有出现中文等宽字符时才会切换到多字节表示。这也是为什么有人会觉得“中文占几个字节”的问题答案不确定。

除了str,还有bytes类型。写入文件、网络传输、处理二进制数据时都是bytes,两者不能混淆。我写爬虫解析网页时就经常需要:

text = "你好" b = text.encode("utf-8") print(type(b), len(b)) # <class 'bytes'> 6,每个汉字utf-8占3字节 back = b.decode("utf-8") print(back) # 你好

这块的坑在于编码格式不匹配导致的UnicodeDecodeError,尤其是从网上拉的旧数据用GBK编码而系统默认UTF-8时。处理外部数据永远要先确认编码,乱码不是玄学,就是编码和解码规则不一致导致的。

2.3 结构化数据容器怎么选

列表、元组、字典、集合这四个内置容器,我一开始觉得就是“装东西的”,用了几个月才琢磨出各自的适用场景:

  • 列表:有序可变的序列,适合保持插入顺序、相同类型的数据。
  • 元组:有序不可变,适合做字典的键、函数返回多值时打包、常量数据等。
  • 字典:键值映射,适合按名字取数据,查找速度是O(1)。
  • 集合:无序不重复,适合去重、交集并集运算。

一个重要特点是元组的不可变是“引用层面的不可变”。元组里存一个列表,列表内容还是能改:

t = (1, [2, 3]) t[1].append(4) print(t) # (1, [2, 3, 4])

有人说这是个bug,其实不是,这是Python语义的体现。选择容器类型时,要基于“是否允许修改内容”和“查找方式”来做判断,而不是看哪个顺手用哪个。

3. 运算规则:从加减乘除到位运算,把这些优先级吃透

3.1 表达式运算的优先级和结合性

初学者在写判断语句时丢括号是家常便饭。Python里运算符优先级从高到低大致是:**指数运算,*///%,+-,然后是位移运算符<<>>,接着是&^|,最后是比较、赋值等。

最容易被忽略的是**是右结合的,2 ** 3 ** 2等于2 ** 9而不是(2 ** 3) ** 2:

print(2 ** 3 ** 2) # 512 print((2 ** 3) ** 2) # 64

这类反直觉的地方,我的经验是绝不依赖记忆优先级,长表达式全部加括号。这不是保守,而是过一两个月回头读代码,加括号的版本能省大量时间。还有连比比较运算在Python里的特殊语义:

x = 5 print(1 < x < 10) # True,等价于 1 < x and x < 10

这种写法很优雅,但新手容易读歪。另外注意and和or的短路:0 and 5结果是0,因为左侧为假就直接返回左侧;0 or 5结果是5。利用短路可以写默认值:

name = user_input or "默认名"

3.2 位运算:从布尔代数到按位或赋值

位运算这块内容,自学的人经常跳过,因为日常业务开发很少直接用到。但一旦接触低层概念——网络掩码、权限系统、状态标记、图形像素操作、CTF题目——位运算就是绕不开的工具。

先记住四张表。与&:只有同为1才为1;或|:只要有1就为1;异或^:不同为1;取反~:0变1、1变0。移位<<和>>把二进制位整体左移或右移,左移相当于乘以2的n次方,右移相当于整除2的n次方:

print(5 << 2) # 20,5*2^2=20 print(5 >> 1) # 2,5整除2=2

在权限系统设计里,可以用位来管理多个开关。这是我写过的一个小例子:

READ = 1 # 0b001 WRITE = 2 # 0b010 EXEC = 4 # 0b100 # 给用户授予读和写的权限 permission = READ | WRITE # 0b011 # 判断是否有执行权限 if permission & EXEC: print("有执行权限") else: print("无执行权限")

按位或赋值|=就更有用了。它和+=的语义类似,就是把permission = permission | WRITE简写为permission |= WRITE。在状态积累场景里是神器:多个条件判断中,每满足一个就用flag |= 某值把对应位置1,最后统一检查。

经典的用法是LeetCode第78题求子集,用1 << i枚举所有组合;或者在做相邻矩阵、图的可达性计算时,用位压缩状态能成倍节省内存。我当时在信息论作业里用位运算处理过8个布尔特征的所有组合状态,比用布尔列表快得多也省得多。

3.3 浮点数运算的几个性能注意点

Python的浮点数运算是基于double类型的,精度有限,速度比纯整数运算略慢,但远不如循环慢。真正要关心的不是单个运算是多快,而是“在循环里不要做多余运算”。

我做列表求和时对比过两个写法:

import time data = [float(i) for i in range(100000)] # 写法一:库函数 start = time.time() s = sum(data) print(time.time() - start) # 约0.003秒 # 写法二:手写循环 s = 0 start = time.time() for x in data: s += x print(time.time() - start) # 约0.012秒

内置函数sum是用C实现的,性能上秒杀手写循环。这个笔记给我的启发是:Python的性能优化核心不是怎么写循环,而是把循环交给内置函数或库去做。

4. 实操笔记:切片、邻接矩阵、小工具实战

4.1 列表和数组切片的各种姿势

切片是Python的招牌语法,[start:stop:step]三个参数,左闭右开。很多人背了“含头不含尾”规则,但换到负数就乱套。我整理了一张速查表:

表达式结果说明
lst[1:4]第1到第3个元素不包含索引4
lst[:3]前3个元素start省略为0
lst[3:]第3个到末尾stop省略为末尾
lst[::-1]反转列表step为-1从右往左
lst[::2]每隔一个取一个步长为2
lst[-3:]最后3个元素负数从末尾数

切片不修改原列表,它会创建新列表。对大型列表频繁切片会额外分配内存,这是性能隐患。如果只需要读数据,用itertools.islice更省内存;如果确实要改原列表,直接在原位置赋值:

lst = [1, 2, 3, 4, 5] lst[1:4] = [20, 30] print(lst) # [1, 20, 30, 5]

注意切片赋值的长度不需要和原切片一致,它可以扩张或压缩列表。

字符串和元组也支持切片语法,它们返回新的字符串和元组。

4.2 用Python构建邻接矩阵

邻接矩阵是图论的经典存储结构,数据结构课程里常讲。我自学时用最基础的方法手写了一个,顺便把数据类型和运算都串起来了。

用一个二维列表表示图结构,假设我们有5个节点、若干条边:

n = 5 edges = [(0, 1), (1, 2), (2, 3), (3, 4), (4, 0)] # 初始化全零矩阵 adj = [[0] * n for _ in range(n)] # 填充边 for u, v in edges: adj[u][v] = 1 adj[v][u] = 1 # 无向图 for row in adj: print(row)

输出结果:

[0, 1, 0, 0, 1] [1, 0, 1, 0, 0] [0, 1, 0, 1, 0] [0, 0, 1, 0, 1] [1, 0, 0, 1, 0]

这里有两个要点:一是初始化用推导式避开共享引用问题(1.2节讲过);二是无向图的矩阵是对称的,双向都置1。如果是带权图,把1改成权重即可,用0或无穷大表示不可达。

如果数据量上来,比如几千个节点,纯Python嵌套列表的读写效率就很拉胯了。这时候上numpy才是正路:

import numpy as np adj = np.zeros((n, n), dtype=int) for u, v in edges: adj[u][v] = 1 adj[v][u] = 1 # 计算每个节点的度数 degree = adj.sum(axis=1) print(degree)

numpy的数组存储是连续内存,向量化运算,比Python列表快一个量级。学数据结构和算法时,用numpy做邻接矩阵的矩阵乘法、计算可达性、求最短路径都非常顺手。这也是我在热词里看到“numpy构建邻接矩阵”“python矩阵”就想展开说说的原因。

4.3 用位运算写一个状态开关的小工具

学完位运算后,我写了第一个有实际用途的小脚本——一个简化版权限检查工具。需求场景:一个命令行小系统,不同的用户操作对应不同的标记位,管理员可以组合授予权限。

# 权限定义 PERM_READ = 1 # 0b001 PERM_WRITE = 2 # 0b010 PERM_DELETE = 4 # 0b100 PERM_EXECUTE = 8 # 0b1000 class PermissionSystem: def __init__(self, user_perms=0): self.user_perms = user_perms def grant(self, perm): self.user_perms |= perm # 按位或赋值 def revoke(self, perm): self.user_perms &= ~perm # 取反再与 def has(self, perm): return (self.user_perms & perm) == perm def show(self): print(f"二进制: {self.user_perms:08b}, 十进制: {self.user_perms}") user = PermissionSystem() user.grant(PERM_READ) user.grant(PERM_WRITE) user.show() # 二进制: 00000011 print(user.has(PERM_WRITE)) # True user.revoke(PERM_WRITE) user.show() # 二进制: 00000001

这段代码把位运算的几种操作全用到了:|=加权限、&检查权限、& ~撤销权限。比起用多个布尔变量,这种方式通过一个整数就能存所有权限状态,且检查逻辑极其简洁。数据库里存权限也是用类似的方式存成整型字段。

5. 自学踩坑实录与排查思路

5.1 新手真的会犯的经典错误

我在学习过程中踩过不少坑,这里挑几个有代表性的分享,都是真实发生过的。

第一个:操作符优先级翻车。我写过if a & b == 0这样的逻辑,原意是判断“a和b都是0”,结果Python实际执行的是a & (b == 0),因为比较操作符优先级高于位运算。这种bug极难排查,因为代码看着没问题。解决方案只有一个:位运算和比较运算混合时全部用括号。

第二个:浮点数直接比较。我在做成绩及格判断时写过if score == 60.0,刚好成绩是60.1减某个小量算出来的,结果明明应该相等却不相等。后来改成if abs(score - 60.0) < 0.0001就一切正常了。

第三个:循环里改正在遍历的列表。我想从列表里删除所有偶数:

nums = [1, 2, 3, 4, 5, 6] for num in nums: if num % 2 == 0: nums.remove(num) print(nums) # [1, 3, 5]——居然对?再试别的数据

换成[1, 2, 4, 5, 6],输出变成[1, 4, 5],删第二个数4时跳过了一个元素。因为删除元素后,列表索引会前移,循环的迭代游标还在原位。标准做法是用新列表暂存或者倒序遍历。

第四个:可变默认参数。我写过def add_item(item, cache=[]): cache.append(item); return cache,第二次调用时发现cache里残留上一次的数据。因为默认参数只在函数定义时创建一次。改成def add_item(item, cache=None): if cache is None: cache = [],这个问题就解决了。

5.2 排查思路:先验证类型,再验证值

我总结出来的一个排查套路:报错时先看类型再讲逻辑。Python是动态类型语言,很多运行时错误是类型不匹配引起的。

比如热词里提到的python连接cmd、winusb、cv2这类问题,安装库时经常遇到“要安装缺失的节点,请先在你的python环境中运行pip install -u --pre comfyui-m”这种提示,本质就是环境依赖不完整。我当时装OpenCV的时候踩过同样的坑。排查步骤一般是:

  • 用pip list查看已安装包版本。
  • 用python -c "import 模块名"测试单个库是否能正常导入。
  • 检查是否盯着多个Python环境在装——系统Python、Anaconda、虚拟环境,三套环境各自装各自的包,管不过来。最好统一用虚拟环境(venv或conda)管理,新项目新建环境,不污染全局。

在调试变量值的时候,我推荐两个思路:一种是print()流式输出定位,但现在更推荐用pdb设置断点,或者用IDE的调试面板一步步看变量变化。第一次用断点看那个列表删除的bug时,立刻就知道问题是游标和索引错位,比瞎试快太多。

5.3 数据验证的启动习惯

不管写什么数据处理脚本,现在的我都会在开头加一段“输入数据自检”。比如写一个处理学生成绩的脚本,先写:

scores = [85, 92, 78, 60, 55] # 自检1:没有空值 assert all(isinstance(s, (int, float)) for s in scores), "存在非数值数据" # 自检2:范围合理 assert all(0 <= s <= 100 for s in scores), "成绩范围异常" # 自检3:长度合理 assert len(scores) == len(set(scores)) or True, "提示信息"

这种自检代码看似增加了几行,实际能节省大量后期排查时间。数据科学项目里,读入的CSV文件经常有空值、脏数据,不检查就往下算,算出来的结果全是错的,还以为是算法问题。数据清洗永远优先于数据处理,这是自学半年后最大的体会。

6. 工具链与后续扩展方向

6.1 IDE和环境配置建议

我在热词里看到大量“python安装教程”“vscode python环境配置”“python环境变量配置”相关词,说明环境问题是新手的一座大山。这里我给个简洁明了的行动方案:

  • Python官网下载安装包,安装时务必勾选Add Python to PATH,这个勾选能避免后续环境变量配置的一堆麻烦。
  • 编辑器用VS Code,安装Python扩展后,按Ctrl+Shift+P调出命令面板,选Python: Select Interpreter,选择你装好的那个Python解释器。
  • 包管理工具使用pip。pip换源以后下载速度快很多,比如用清华或阿里云的镜像源,文件里写上index-url即可。
  • 每个项目尽量用虚拟环境隔离依赖,用python -m venv venv创建,然后在VS Code里选中它作为解释器。

这套流程我已经用烂了,也帮同事配过,基本二十分钟能搞定。

6.2 numpy和数据结构:下一个必学模块

如果把这篇笔记总结成一句话,那就是:数据存储解决“数据长什么样、存在哪”,运算解决“数据能怎么变”。搞清楚这两块的底层机制后,后续学numpy、pandas、机器学习都会顺畅很多。

numpy数组和Python列表最核心的区别就是存储方式:列表存的是对象的引用,内存不连续;numpy数组存的是连续的同类型数值,配合向量化计算,速度能提升数十倍。我在热词里看到“python构建邻接矩阵”“python矩阵0”相关词,如果要做网状数据、矩阵运算,强烈建议直接用numpy。

我近期准备专门为numpy的存储模型写一篇笔记——为什么ndarray的切片是视图而不是复制、广播机制到底怎么工作、np.array和np.asarray的区别,等等。目前这些内容还在整理实验代码阶段。

7. 几个自学者最容易忽略的细节

7.1 内存的释放与引用计数

Python有垃圾回收机制,但这不代表你可以完全不管内存。我在循环中处理大量数据时,会发现内存占用只增不减——因为每轮循环创建的对象都有引用链条指向它们,直到函数结束才被回收。

手动释放的办法:

del large_list # 删除引用

更主动的是用gc.collect()强制回收,但这个调用有开销,不推荐频繁执行。另一个常见问题是循环中追加数据到列表不加以控制,例如爬虫写数据时,结果列表越攒越大,几百MB内存就这么没了。此时应该分块处理,或者边抓边写入磁盘。

7.2is和==不要混用

==比较的是值是否相等,is比较的是两者是否指向同一个内存对象。初学者很容易犯这个错:

a = 100 b = 100 a is b # True,小整数缓存 a = 1000 b = 1000 a is b # False,超范围的新对象

判断是否为空、是否为None,官方推荐用is None。但判断两个列表内容是否一致,必须用==。这个坑我在做单测比对结果时踩了一次,从那次起就养成了“比较内容用==、比较身份用is”的习惯。

7.3 内置函数和标准库是你的武器库

真正动手写代码后我发现,很多需求标准库已经提供了好用的工具,不需要自己造轮子:

  • collections.defaultdict:字典默认值,避免KeyError。
  • collections.Counter:一行代码统计频率。
  • itertools.groupby:分组处理数据。
  • functools.lru_cache:递归记忆化缓存,刷题利器。
  • random、math、os、sys:常用的底层设施。

我写数据处理脚本时的习惯是,先过一遍标准库有哪些相关模块,再看看第三方库,最后才自己实现逻辑。有时候你觉得麻烦,往往是因为还没找到合适的工具。

写在最后的一点个人体会

学Python最大的好处是反馈极快——写几行代码就能看到结果,这种即时正反馈特别适合自学者保持动力。但基础的数据存储和运算部分,恰恰是这种“快反馈”掩盖了理解深度的地方。你确实能把a = 1写出来,但只有理解了标签机制、不可变语义、浮点精度陷阱、位运算的用途,后面学函数、学类、学算法时才能举一反三,不靠死记硬背。

我现在回看自己最初写的笔记,大部分内容现在都已内化成直觉。这篇算是系列的第一篇,后续还会写关于函数传参、文件读写和numpy存储模型的笔记。如果非要给各位正在自学的人一个建议:每学一个新概念,就拿debugger断点看一眼内存里的对象行为;每写一个脚本,就在开头加数据自检。这两个习惯比多写一百行代码都管用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询