Python3通过字符串访问与修改局部变量

技术背景

在Python中定义一个函数时,就会把变量空间划分为全局变量(global)与局部变量(local),如果是定义在一个类的成员函数中,那么就还有额外的成员变量(self)空间。那么,如果在实际操作中,想把这几种不同的变量空间做一个分离的话,有没有办法呢?

读取和修改局部变量

首先来看一下局部变量的读取,一般有locals()、vars()和sys._getframe(0).f_code.co_varnames这几种方法,另外有一种sys._getframe(0).f_locals的方法,其实等价于locals(),相关的实现代码如下:

x = 0

class Obj:
    def __init__(self,y):
        self.func(y)
        
    def func(y, z=1):
        m = 2
        print (locals())
        print (vars())
        print (__import__('sys')._getframe(0).f_code.co_varnames)

if __name__ == '__main__':
    Obj(2)

该代码的运行结果如下:

{'self': <__main__.Obj object at 0x7f5cf5e74e50>, 'y': 2, 'z': 1, 'm': 2}
{'self': <__main__.Obj object at 0x7f5cf5e74e50>, 'y': 2, 'z': 1, 'm': 2}
('self', 'y', 'z', 'm')

在vars方法不加具体变量名的时候,就是等价于locals方法,两者返回的结果都是字典格式。如果是一个类中的成员函数下执行locals或者vars,会附带一个__main__.Obj object的变量,相当于所有self的成员变量,其实也是局部变量的一部分。而如果使用co_varnames的方法,那么得到的就是所有局部变量的名称,我们也可以在例子中额外定义一个self的成员变量:

x = 0

class Obj:
    def __init__(self, y):
        self.p = 5
        self.func(y)

    def func(self, y, z=1):
        m = 2
        print(locals())
        print(vars())
        print(__import__('sys')._getframe(0).f_code.co_varnames)

if __name__ == '__main__':
    Obj(2)
    # {'self': <__main__.Obj object at 0x7fe9aac0ce50>, 'y': 2, 'z': 1, 'm': 2}
    # {'self': <__main__.Obj object at 0x7fe9aac0ce50>, 'y': 2, 'z': 1, 'm': 2}
    # ('self', 'y', 'z', 'm')

可以发现,所有的成员变量都被放在了self中。并且需要注意的是,全局变量x自始至终都没有在局部变量中出现。那么既然我们可以通过这种方式分离出局部变量,或者是局部变量的名称,那我们如何去调整或者修改这些局部变量呢?首先我们需要知道,locals()方法返回的变量是一个copy,也就是说即使修改了locals方法返回的结果,也不能真正的改变局部变量本身的值,这样描述可能有点抽象,我们直接看下这个案例:

x = 0

class Obj:
    def __init__(self,y):
        self.func(y)

    def func(self, y, z=1):
        m = 2
        vars()['z']=2
        locals()['n']=3
        print (locals())
        print (z)

if __name__ == '__main__':
    Obj(2)

在这个案例中分别通过vars方法和locals方法去修改局部变量的值,最终的输出结果如下:

{'self': <__main__.Obj object at 0x7f74d9470e50>, 'y': 2, 'z': 1, 'm': 2, 'n': 3}
1

首先要解释一下为什么这个案例中没有打印n这个变量,前面提到vars和locals的返回值都是真实变量的一个copy,因此我们不管是修改也好,新增也好,内容不会同步到变量空间中去,也就是说,此时的局部变量n还是处于一个没有定义的状态,只是在locals或者vars的字典中存在,此时打印只会报错NameError。而z的最终打印输出是1,这表明z的值确实没有受到对vars的变量修改的影响。那到底有没有办法可以通过字符串去修改局部变量呢(不同步到全局变量)?答案是有的,但是这个方案非常的hacky,请看如下示例:

import ctypes

x = 0

class Obj:
    def __init__(self,y):
        self.func(y)

    def func(self, y, z=1):
        m = 2
        __import__('sys')._getframe(0).f_locals.update({
            'z': 2,'n': 3
        })
        ctypes.pythonapi.PyFrame_LocalsToFast(
            ctypes.py_object(__import__('sys')._getframe(0)), ctypes.c_int(0))
        print (locals())
        print (z)

if __name__ == '__main__':
    Obj(2)

这个案例是使用了Cython的方案直接去修改了数据帧的内容,而这里所使用的f_locals其实本质上就是locals。经过一番运行,输出结果如下:

{'self': <__main__.Obj object at 0x7fea2e2
a1e80>, 'y': 2, 'z': 2, 'm': 2, 'n': 3}
2

此时局部变量z是被成功修改了的,但是在前面提到的,即使我们通过这种方法修改了局部变量的值,但是依然不能通过这个方案去创建一个新的局部变量,此时去执行print (n)的话,依然会有报错提示。

读取和修改全局变量
相比于修改局部变量,其实查看修改全局变量要显的更加容易。首先我们用一个示例演示一下如何查看所有的全局变量:

x = 0

class Obj:
    def __init__(self,y):
        self.func(y)

    def func(self, y, z=1):
        m = 2
        print (globals())

if __name__ == '__main__':
    Obj(2)

获取局部变量的方式有很多,但是获取全局变量一般就是globals或者等价的f_globals。上述代码执行输出如下:

{'__name__': '__main__', '__doc__': None, '__package__': None,
 '__loader__': <_frozen_importlib_external.SourceFileLoader object at 0x7f202632ac40>,
 '__spec__': None, '__annotations__': {}, '__builtins__': <module 'builtins' (built-in)>,
 '__file__': 'xxx.py', '__cached__': None, 'x': 0, 'Obj': <class '__main__.Obj'>}

用这种方法我们发现了全局变量x,而在同一个函数内的几个局部变量,就没有显示在globals的key中。而不同于locals变量的是,globals函数返回的是一个真实的数据,是可以直接修改,并且在全局生效的。比如我们在函数内定义或者修改全局变量:

x = 0

class Obj:
    def __init__(self,y):
        self.func(y)

    def func(self, y, z=1):
        global m
        m = 2
        globals()['x']=3

if __name__ == '__main__':
    Obj(2)
    print(globals()['x'])
    print(globals()['m'])
    # 3
    # 2

在这个例子中我们就可以发现,不仅仅是修改的x值生效了,新建的m也同步到了全局变量中,这样就可以比较容易的划分全局变量和局部变量再进行统一赋值或者修改。

读取和修改成员变量

在python中每一个定义的object都有一个隐藏属性__dict__,这是一个字典,其中包含了所有的成员变量名和成员变量值。在前一篇博客中,我们就介绍了通过__dict__去给类中的成员变量进行赋值,非常的方便。我们可以通过一个示例来看看__dict__中所包含的内容:

x = 0

class Obj:
    def __init__(self,y):
        self.m = 2
        self.func(y)

    def func(self, y, z=1):
        print (self.__dict__)

if __name__ == '__main__':
    Obj(2)
    # {'m': 2}

从输出结果中我们就可以看到,__dict__输出的内容非常的纯净,就是所有的成员变量名和变量值。而成员变量虽然是一个对象的属性,但是其操作方式跟全局变量globals是非常接近的,不像locals一样只读,具体示例如下:

x = 0

class Obj:
    def __init__(self,y):
        self.m = 2
        self.func(y)

    def func(self, y, z=1):
        self.m = 5
        self.__dict__['n'] = 6
        print (self.__dict__)
        print (self.m, self.n)

if __name__ == '__main__':
    Obj(2)
    # {'m': 5, 'n': 6}
    # 5
    # 6

在这个案例中,我们修改了成员变量的值,也使用__dict__新建了一个成员变量的值,可以看到最终都有同步到变量空间中,这样就完成了成员变量的修改。

总结概要

Python本身是一门比较灵活便捷的编程语言,但是便捷往往有可能伴随着一些风险,比如exec和eval等内置函数的实现,有可能导致sandbox escaping的问题。而有时候我们又需要一些批量化的操作,比如批量化的创建或者修改局部、全局或者是成员变量,这样就需要我们首先要把所有的变量名存成字符串,在需要的时候再作为变量名去调用。在这篇文章中,我们介绍了一系列非exec和eval的操作(并不是说没有风险,也引用了ctype和sys定义的数据帧),来查看和定义、修改所需的各种变量。

在这里还是要推荐下我自己建的Python学习Q群:1150305204,群里都是学Python的,如果你想学或者正在学习Python ,欢迎你加入,大家都是软件开发党,不定期分享干货(只有Python软件开发相的),包括我自己整理的一份2024最新的Python进阶资料和零基础教学,欢迎进阶中和对Python感兴趣的小伙伴加入!