前言

  最近和吴真大大讨论了一下 QBinder 框架对于数据管理的问题。
  Python 语言本身会遇到一个比较麻烦的问题,那就是数据传递给 QBinder 框架之后,需要用 QBinder 构建的数据结构来更新数据。
  如此才能实现组件的更新。
  然而实际的应用场景下,编程的用户更加希望 QBinder 构建了绑定之后直接用原始的数据进行更新。
  所以为此需要 Python 能够实现类似 C++ 的指针的效果,直接处理原始数据。

  基于这样的大前提下,我研究了一段时间,感受到了 Python 语言本身的瓶颈,很难解决(:з」∠)
  而且官方的也不推荐使用 Python 修改内存,因为 Python 有自己的 gc (垃圾回收机制) ,胡乱修改反而会出更多问题。
  虽然这个提议很有诱惑力,但是要实现反而会引发更多问题。
  参考 Vue 和 React 这些前端框架,也无法做到一套数据实现 框架之间 直接互通,都需要将数据转换到框架的数据结构之下。
  因此这个提议被放弃了,如果各路 仁人志士 有不错的提议 ,欢迎与我讨论交流 ~

  所以这篇文章就是基于这个大前提的研究做的一些总结。
  从中有可以学到 Python 很多不为人所知的黑科技,胶水语言的名堂可不是白吹的。
  语言表层简单直白,底层也依然有内置 ctypes gc struct array 库来实现交互。
  当然还有黑科技 disast 来处理 Python 汇编 和 抽象语法树。
  对比 node.js 大量的第三方库,我觉得 Python 内置库真的很完善。

利用 ctypes 强行内存修改

  当时我在 Stack Overflow 查找相关的方法没有找到,想来 Python 应该是做不到的。
  没想到吴真大大做到了,我拿到了它的代码别提有多兴奋了。

1
2
3
4
5
6
7
8
9
10
import ctypes
def mutate(obj, new_obj):
if sys.getsizeof(obj) != sys.getsizeof(new_obj):
raise ValueError('objects must have same size')

mem = (ctypes.c_byte * sys.getsizeof(obj)).from_address(id(obj))
new_mem = (ctypes.c_byte * sys.getsizeof(new_obj)).from_address(id(new_obj))

for i in range(len(mem)):
mem[i] = new_mem[i]

  上面限制了数据长度必须相等,不会修改到 内存 的其他地方。
  不过我觉得不够灵活,于是改成来下面的样子。

1
2
3
4
5
6
7
8
9
10
11
12
13
import ctypes
def mutate(obj, new_obj):
mem = (ctypes.c_byte * sys.getsizeof(new_obj)).from_address(id(obj))
new_mem = (ctypes.c_byte * sys.getsizeof(new_obj)).from_address(id(new_obj))
for i in range(len(mem)):
mem[i] = new_mem[i]

# NOTE 用法演示
a = "a"
print(a) # 打印 a
mutate(a,"b")
print(a) # 打印 b

  探讨这个函数的作用就得先理解清楚 Python 赋值的作用,还有为什么 Python 没有 i++ 这类的自增语句。
  这里特别感谢吴真大佬的讲解,我之前对这方面存在一些误区,现在更加清楚了~


  首先需要知道 Python 存在 mutable 和 immutable 对象,可以参考 知乎链接

  Python 赋值其实并不是全部都如 C++ 之类的语言会直接构建一个变量对象分配内存空间。
  Python 内部会有 Cache 缓存,将常用的 immutable 如 小整数 之类的数据预先分配好内存空间。

1
2
3
4
5
a = 1
b = 1
print(id(a))
print(id(b))
print(id(a) == id(b)) # 打印 True

  所以当变量同时赋值到 1 的时候,其实背后只是一个函数作用域的字典,将 'a' 键和 'b'键分别指向了 1 的内存地址。
  在这个过程并没有创建 1 而是将地址指向事先创建好的 1 上。

  所以也就是这个区别,Python 没有自增操作符, Python += 的整数操作符其实也是重新将数据指向了新的数据而没有在原来的数据上进行自增。

  所以用上面内存修改的方式修改了 缓存 的数据会出现非常非常神奇的情况。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import ctypes
def mutate(obj, new_obj):
mem = (ctypes.c_byte * sys.getsizeof(new_obj)).from_address(id(obj))
new_mem = (ctypes.c_byte * sys.getsizeof(new_obj)).from_address(id(new_obj))
for i in range(len(mem)):
mem[i] = new_mem[i]

a = 1
print(a) # 打印 1
mutate(1,2)
print(a) # 打印 2

b = 1
print(id(1)) # 2841718882632
print(id(2)) # 2841718882608
print(id(1) == id(2)) # 打印 False
print(b) # 打印 2

  因为上面的 mutate 将 1 的内存数据直接修改成了 2 的数据。
  所以 b = 1 结果变成神奇的 2。
  不过千万不要在项目上使用这种写法,保不准就让你程序崩溃了。
  所以 Python 内存修改是非常危险的,上面的方案演示了 修改 的 可能性。
  但是从 Python 的设计理念来看是完全不希望被这么骚操作的。

forbiddenfruit 研究

  虽然上面的方案被否了,我还是有点不死心,毕竟 mutable 对象的内存修改总归是留有余地的吧。
  于是在这个方向搜索偶然找到了 forbiddenfruit 这个神奇的 Python 库。

链接

  我是在上面的 Stack Overflow 的回答里发现这个库的。
  正如回答下的第一条评论所说,这个库做的黑科技可能导致内存出错,引发各种不稳定,项目上使用就得慎重了。
  forbiddenfruit Github链接

  forbiddenfruit 实现对内置类型的函数扩展。
  可以实现 JavaScript 的 String.prototype 给内置字符串添加方法的效果。

1
2
3
4
5
6
import sys
int.__dict__["hello"] = lambda self:sys.stdout.write("hello")
# Traceback (most recent call last):
# File "g:/repo/QBinder/research/forbiddenfruit/test_c.py", line 24, in <module>
# int.__dict__["hello"] = lambda self:sys.stdout.write("hello")
# TypeError: 'mappingproxy' object does not support item assignment

  默认情况下可以通过 __dict__ 查看默认类型的方法。
  但是无法如上面的代码所示进行方法的添加。

  forbiddenfruit 的代码量也不多,也就 500 行左右。
  其中抽丝剥茧的核心部分如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
from __future__ import print_function
import gc
import ctypes

def patchable_builtin(klass):
refs = gc.get_referents(klass.__dict__)
assert len(refs) == 1
return refs[0]

dikt = patchable_builtin(int)
print(dikt)

dikt["square"] = lambda self:print(self**2)
a = 2
a.square() # 打印 4
# NOTE 这样会报错
# 3.square()
(3).square() # 打印 9

  主要利用 gc.get_referents 重新获取到的字典对象就不会导致赋值出错了。

  之所以还需要大量额外的代码,只要是因为需要考虑到修改诸如 __str__ 的内置方法时要确保其能够正常触发。
  所以需要调用 ctypes.pythonapi 来实现很多黑科技的操作。
  这个部分就需要更底层的 Cython 来驾驭了。
  Python ctypes 库提供了一定程度的 C 编程操作,因此 forbiddenfruit 不需要 Cython 编译,兼容性很强。

总结

  后续的东西比较偏向底层,我就没有深入研究了。
  到头来 Python 内存修改虽然可以实现,但是却完全不是个好方案。