为什么 Python 会有.venv 虚拟环境的概念?

2024 年 1 月 10 日
 bocchi1amos


本人刚从 java 、c#转过来,因为工作需要,这两天开始研究 python 工程。
(其实以前也接触过,但只限于会写些代码层次的基础语法)
现在新人有个疑惑,为什么 python 会有.venv 这种概念,资料说是项目编译环境隔离,每个项目有自己的解释器环境什么的。其实这种给每个项目搞内环境部署,很像是 java 开发里给每种服务的 docker 容器都单独打包一个独立的 jdk ?

11410 次点击
所在节点    Python
69 条回复
codehz
2024 年 1 月 11 日
@est 然后发现不同平台有不同的本地库依赖:比如常见的那些机器学习项目,直接复制的话几乎肯定会炸( win->linux)
est
2024 年 1 月 11 日
@codehz .so 文件?那是你打包技术不行。关 py 什么事。。

就说了,如果弄 JNI 不一样的问题。
xingheng
2024 年 1 月 11 日
@est #32 但凡看到别人用 sys.path.insert 我就想给他一脚
est
2024 年 1 月 11 日
@xingheng

看到我 32 楼贴那个链接没?点开他,拉到最下面看看是啥?

课后习题:找到该代码作者 blame 出来,上 gayhub 踢他。
cbdyzj
2024 年 1 月 11 日
因为 Python 没有 node_modules ,也没有 Maven
fgwmlhdkkkw
2024 年 1 月 11 日
@kneo 啊?!
kuanat
2024 年 1 月 11 日
“为什么要有虚拟环境”这个问题比较好回答,就是为了隔离。我觉得楼主真正想问的是,或者说这个问题有意义的点在于,为什么 Python 没有“现代”一些的包管理机制?

楼上已经有不少人提到了,Python 不支持同时安装一个包的多个版本。要解释“为什么”不支持,那就比较麻烦了。

这要回到 Python 最早设计包这个概念的时候,当初的决定导致了,不可能在不做 breaking change 的情况下,改变这个行为或者说添加版本机制。这个设计决策的时间点在 1.X 版本,估计大部分人都没有用过,好在 PEP 文档还是可以一窥设计思路的。


我这里提个看上去无关的问题,为什么 Python 需要 __all__ 来支持 from XXX import *?

答案是 Python 的 import 机制是基于文件系统的,包名就是路径名。基于文件系统就意味着,包名无法区分 XXX/Xxx/xxx 的,因为文件系统不确定是否是大小写敏感的。

你可能会觉得包名就是路径名,那给包名后面加上版本号不就行了吗?理论上是的,很多现代一点的语言的包管理就是这么做的。那 Python 为什么做不到?主要原因是 Python 流行起来之后,不可能在不影响生态的情况下做这样的改动了。理论上 Python 2/3 的时候有这个机会,但是 Python 没有做这个变动,这与 import 的实现机制有关。

Python 的 import hook 包括标准库,都涉及到自举( bootstrap ),所以是用 C 写的。更重要的是,增加版本号意味着 Python 要官方实现包管理机制,而包管理是个理论和实践都非常难的事情。Python 选择继续采取社区的“虚拟环境”方案,我个人认为这是个正确的选择。


这个问题到这里其实就差不多说清楚了,不过我打算再补充一些内容。

包管理问题的难点在于版本选择,目的是要找到某个包的完整的(所有)、兼容的(不能包含相冲突的,比如同一个包的两个版本)依赖。这个问题有多难?我印象大概 2017 年才有证明,这是一个 NP 完全问题。NP 完全的意思就是,不知道是否存在一个可以稳定在多项式时间内,完成这个解析过程的算法。

所以几乎所有的传统包管理软件只能做个二选一,要么选择正确但是有可能很慢,要么选择时间可控但是可能出错。实际上几乎所有的包管理都选择正确但是慢,或者说正确但是不清楚要花多长时间。前面的证明给了包管理器新的设计思路,要增加限制条件或者说妥协,不去追求完美解决 NP 问题,只做工程上“好用”的解决方案。

具体理论不展开了,我这里简单说一下“现代”包管理的两个基础假设。一是高版本总是向后兼容低版本,不兼容的情况使用 semantic versioning 的 MAJOR 区分。二是主包可以改写间接依赖的版本。第二条不好理解,举个例子:A 是开发者要构建的包,这里叫主包,它直接依赖 B 和 C==1.2.0 ,其中 B 又依赖 C==1.1.0 。这时候要构建 A ,那么 A 对于 C 的要求就会覆盖 B 对于 C 的要求,又因为 C 的 MAJOR 版本没有变,理论上 C==1.2.0 是同时满足 A/B 需求的。当然单独构建 B 的时候,B 作为主包,依旧会使用 C==1.1.0 的版本。这一条放宽了之后,NP 问题的限制就不存在了,这个问题就有了多项式级别的解法。

我接触过的包管理,似乎只有 Rust(cargo)/Go 是基于新理论(即主动规避 NP 问题)的。区别是 Rust 总是选最高版本,而 Go 选最低版本。Go 的实现比 Rust 简介很多很多。其他的包管理都还在跟 NP 问题作斗争。
kuanat
2024 年 1 月 11 日
@kuanat #47

再做一点补充。

Python 3 是 2008 年左右发布的。这个时间点上,如果 Python 决定重写 import hook ,将版本号纳入成为包名的一部分,支持安装同一个包的多个版本,就没有今天虚拟环境什么事了。

这个改动和“不做官方包管理”不冲突。不做官方包管理是正确的选择,在那个时间点上,很难做得好,特别是这些实现都要用 C 来写。

但是底层限制所限,同一个解释器环境不能安装同一个包的多个版本,那包管理器是永远无法摆脱虚拟环境的。
bocchi1amos
2024 年 1 月 11 日
@kuanat 多项式时间非确定性问题吗,从数学视角研究得这么深……真的 6 的
cnt2ex
2024 年 1 月 12 日
python 的虚拟环境和其他语言的依赖管理工具是类似的,解决都是依赖管理和隔离的问题。

pip 安装包时,把包安装在了系统/用户路径。而不同项目使用同一个系统/用户路径就会有依赖冲突的问题。所以有了虚拟环境这个概念,来隔离不同项目使用的不同版本的依赖。

而其他语言的工具链可以直接把包安装到当前项目的路径,然后当前项目的库路径加入到库加载路径里(比如 CLASSPATH )。因此这些语言没有虚拟环境这个概念。

实际上楼上也有人提到了,python 其实也可以学习这个做法,把所有包安装到项目的目录下,然后设置 PYTHONPATH 。
fkdtz
2024 年 1 月 12 日
@kuanat 47 楼说到问题的本质了。
话说回来按照现有的虚拟环境的方式做隔离也未尝不是一个好的方式,从解释器到依赖包再到文件路径统统可以隔离,完全在自己的掌控之内,这种感觉让我着迷。
cnt2ex
2024 年 1 月 12 日
还有所谓多版本共存的问题,这不是真正的问题所在。

对于编译型的语言,编译(链接)时,会在生成的可执行的文件里记录对应的版本号。在运行时,则会根据可执行文件中记录的版本去动态链接对应的库。这里的多版本库的共存的机制其实不是语言本身的,而是 ELF 文件格式提供的(以 linux 为例,windows 应该也有类似的机制)。

但对于脚本型语言,你的脚本里没有记录依赖的库版本(并且也不该在代码级别处理版本依赖)。
因此即使你在安装的库的版本里,加了版本后缀作为区分又有什么用呢?因为你脚本本身没有记录到底使用的哪个版本。
allenby
2024 年 1 月 12 日
这么搞我觉得 python 越来越臃肿了,时不时找不到依赖😅
frostming
2024 年 1 月 12 日
@kuanat 说得很到位

>A 是开发者要构建的包,这里叫主包,它直接依赖 B 和 C==1.2.0 ,其中 B 又依赖 C==1.1.0 。这时候要构建 A ,那么 A 对于 C 的要求就会覆盖 B 对于 C 的要求,又因为 C 的 MAJOR 版本没有变,理论上 C==1.2.0 是同时满足 A/B 需求的。

这个 TIL 了。semantic versioning 在 Rust 上是可以强制的,甚至 linter 都能检查出 API breakage 。但对于一门像 Python 这样的 duck typing 的动态语言,有没有 breaking change 这属于一个薛定谔的问题。所以越来越多的包都倾向于使用 calendar versioning 了。这导致在 NP 问题上依然无解。
iorilu
2024 年 1 月 12 日
按理只需把 pip 改成默认只安装在当前目录下应该能解决大部分问题把
kuanat
2024 年 1 月 12 日
@frostming #54

我又看了一遍自己写的东西,其实不太准确,我这重新描述一下。

版本选择之所以是 NP 问题,源于好几个假设。其中有几个假设是没法改变的,能够改变的还有两个:

- 一个包可以声明自己依赖零个或多个特定版本的包。
- 不允许同时选择同一个包的两个不同版本。

第一条过于严格了。如果把特定版本换成一个范围,这个问题就简单多了。第二条也可以适当放宽,和第一条一样,都是建立在 semantic versioning 主动向后兼容的假设之上。虽然不能引入同一个包的 1.X.X 和 1.Y.Y ,但是可以同时引入 1.X.X 和 2.Z.Z 。

这两个条件放宽之后,NP 问题就不存在了。但是对 APT/RPM/Node/Python 这些起步较早的社区来说,这两个要求依然太难了。

至于主包覆盖依赖的限制,这是用来解决菱形依赖的,不是规避 NP 问题的核心措施。还是之前的例子,只要保证 C 符合 semantic versioning 向后兼容就好了。这个措施实际体现的是解决问题的思路转变。

具体的来源比较分散记不清了,我凭印象做个总结。第一个做 NP 规避的应该是 Rust/Cargo 。之后 Go 做了一个叫 Dep 的类 Cargo 实现,实践了一段时间,吸取经验教训,重做之后形成了正式的 Go mod 方案。Rust/Go 用相同的方法解决菱形依赖,但是基础思路是不一样的。这一点从 Rust 总是选择最高版本,而 Go 总是选择最低版本能看出来。

这里想象一个场景:A==1.1.0 依赖 B==1.5.0 ,之后 B 发布 B==1.6.0 ,由于 B 引入了非兼容改变,导致 A==1.1.0 无法依赖 B==1.6.0 构建。这个时间节点,按照 Rust 的设计,所有依赖 A 的用户(包括依赖 A 的老用户)都会受到影响,而 Go 这边只有同时依赖 A==1.1.0 和 B==1.6.0 的新用户(老用户不受影响)才会受影响。

之后 A/B 至少有一方要打补丁发新版。Rust 认为要么 A 在 B 没有提供补丁的情况下,主动发新版声明不兼容 B==1.6.0 ,要么 B 发新版修复对 A 的支持。实际上在开源社区这两个事情都是很难的。Go 的设计是除非用户主动升级,都会保持作者发布时的最低依赖版本,(毕竟发布那个时刻的版本依赖几乎是都可以构建的)这样就给 A/B 争取了非常多的修复时间。

Rust 的设计者更希望为开发者提供最好的体验,希望一己之力解决所有问题。Go 的设计思路是依靠社区合作,依赖所有人主动去帮助解决对自己来说比较容易,而别人不好解决的问题。所以你能看到,Go 官方一直不遗余力地推动向后兼容,因为 Go 的整套实现逻辑都强依赖整个社区对于兼容性规范的共识。



最后赞美 pnpm ,感谢!
cnt2ex
2024 年 1 月 13 日
@kuanat
> 为什么 Python 需要 __all__ 来支持 from XXX import *
__all__不是拿来指定哪些是公开接口,哪些不是吗?你给出的回答似乎和为什么需要__all__没什么关系???
kuanat
2024 年 1 月 13 日
@cnt2ex #57

这里我说得太简略了。

在设计这个 import 机制的 1.x 版本,要实现 import * 需要去文件系统里遍历有哪些模块。底层文件系统可能会对某个名为 xyz 的模块,返回 XYZ/Xyz/xyz 几个不同的结果。当时对于这个问题的解决方案是设计 __all__ 让维护者自己声明是哪一个。

之后的 PEP 才明确了模块名应该( should )全小写,import 的所有符号都是小写。再之后 __all__ 才成为一种工程上的控制机制。
cybort
2024 年 1 月 13 日
其实如果项目动态链接 linux 安装的包,也会有这个问题,docker 比较全面,venv 比较轻量罢了
cybort
2024 年 1 月 13 日
另外就是高版本兼容低版本在实践上很多时候是不可行,因为总有一些东西会 depreciate 掉,而要求开发者去考虑每个版本同时存在的情况,这个要求有点高了。

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://v2ex.ih06.com/t/1007645

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX