首先,实际上有一种更简单的方法。我们想要做的只是改变print
打印的内容,对吧?
_print = print
def print(*args, **kw):
args = (arg.replace('cat', 'dog') if isinstance(arg, str) else arg
for arg in args)
_print(*args, **kw)
或者同样地,您可以对
print
进行猴子补丁,而是对
sys.stdout
进行猴子补丁。
此外,exec … getsource …
的想法并没有什么问题。当然,这个想法存在很多问题,但比接下来的要少得多...
但是,如果你确实想要修改函数对象的代码常量,我们可以这样做。
如果你真的想要玩弄代码对象,你应该使用一个类似于bytecode
(在完成后)或byteplay
(在此之前或对于旧版本的Python)的库,而不是手动操作。即使对于这种微不足道的事情,CodeType
初始化器也很麻烦;如果你确实需要做像修复lnotab
这样的事情,只有一个疯子才会手动操作。
此外,毋庸置疑,并非所有的Python实现都使用CPython风格的代码对象。这段代码将在CPython 3.7中工作,并且可能会在至少2.2版本以及一些小改动后向后兼容所有版本(不是代码黑客的东西,而是像生成器表达式这样的东西),但它不会在任何版本的IronPython中工作。
import types
def print_function():
print ("This cat was scared.")
def main():
co = print_function.__code__
consts = tuple(c.replace("cat", "dog") if isinstance(c, str) else c
for c in co.co_consts)
co = types.CodeType(
co.co_argcount, co.co_kwonlyargcount, co.co_nlocals,
co.co_stacksize, co.co_flags, co.co_code,
consts, co.co_names, co.co_varnames, co.co_filename,
co.co_name, co.co_firstlineno, co.co_lnotab,
co.co_freevars, co.co_cellvars)
print_function.__code__ = co
print_function()
main()
使用代码对象进行hack可能会出现什么问题?主要是段错误,耗尽整个堆栈的RuntimeError,可以处理的更正常的RuntimeError,或者在尝试使用它们时可能会引发TypeError或AttributeError的垃圾值。例如,尝试创建一个只有RETURN_VALUE但堆栈上没有任何内容的代码对象(对于3.6+的字节码b'S\0',以前是b'S'),或者在字节码中有LOAD_CONST 0时,使用空元组作为co_consts,或者将varnames减1,以便最高的LOAD_FAST实际上加载freevar/cellvar单元格。如果lnotab足够错误,运行调试器时你的代码将仅产生段错误。
使用bytecode或byteplay不能保护你免受所有这些问题的影响,但它们具有一些基本的健全性检查和好用的辅助工具,例如插入一段代码并让它担心更新所有偏移量和标签,以便您不会弄错等等。(此外,它们使您无需键入那个荒谬的6行构造函数,并且无需调试由此产生的愚蠢的打字错误。)
现在我们来看第二个问题。
我提到了代码对象是不可变的。当然,常量是一个元组,所以我们不能直接更改它。而元组中的东西是一个字符串,我们也不能直接更改它。这就是为什么我不得不构建一个新字符串来构建一个新元组来构建一个新的代码对象。
但如果你可以直接更改字符串呢?
嗯,在底层深处,一切都只是指向某些C数据的指针,对吧?如果你正在使用CPython,那么有
一个C API用于访问对象,
你可以使用ctypes
从Python内部访问该API,这是一个非常糟糕的想法,他们在stdlib的ctypes
模块中放了一个pythonapi
。 :) 你需要知道的最重要的技巧是,
id(x)
是
x
在内存中的实际指针(作为
int
)。
不幸的是,字符串的C API不允许我们安全地访问已经冻结的字符串的内部存储。所以,我们就
读取头文件,自己找到那个存储。
如果你正在使用CPython 3.4 - 3.7(旧版本不同,未来也不知道),来自由纯ASCII组成的模块的字符串文字将使用紧凑的ASCII格式存储,这意味着结构体会提前结束,并且紧接在内存中的是ASCII字节缓冲区。如果您在字符串中放置非ASCII字符或某些非文字字符串,它将会中断(可能会段错误),但您可以阅读其他4种访问不同类型字符串缓冲区的方法,为了使事情稍微容易一些,我使用了我的GitHub上的
superhackyinternals
项目。(它故意不可通过pip安装,因为您真的不应该使用它,除非是用于实验本地构建解释器等。)
import ctypes
import internals
def print_function():
print ("This cat was scared.")
def main():
for c in print_function.__code__.co_consts:
if isinstance(c, str):
idx = c.find('cat')
if idx != -1:
p = internals.PyUnicodeObject.from_address(id(c))
assert p.compact and p.ascii
addr = id(c) + internals.PyUnicodeObject.utf8_length.offset
buf = (ctypes.c_int8 * 3).from_address(addr + idx)
buf[:3] = b'dog'
print_function()
main()
如果你想玩这个东西,那么在内部实现上,int
比 str
简单得多。而且通过更改 2
的值为 1
来破坏程序的行为也更容易被猜测到,对吧?实际上,不需要想象,我们可以直接试一下(再次使用 superhackyinternals
中的类型):
>>> n = 2
>>> pn = PyLongObject.from_address(id(n))
>>> pn.ob_digit[0]
2
>>> pn.ob_digit[0] = 1
>>> 2
1
>>> n * 3
3
>>> i = 10
>>> while i < 40:
... i *= 2
... print(i)
10
10
10
...假装代码框有无限长度的滚动条。
我在IPython中尝试了同样的事情,第一次尝试在提示符处评估2
时,它进入了某种不可中断的无限循环。大概它在 REPL 循环中使用数字2
,而标准解释器没有这个特性?
42
更改为23
,为什么这是一个坏主意就更加明显了,而将值“我的名字是Y”
更改为“我的名字是X”
为什么是个坏主意却不那么明显。 - abarnert