Python能否进行赋值重载?

111

是否有一种类似于 __assign__(self, new_value) 的魔术方法可以重载赋值运算符?

我想禁止实例的重新绑定:

class Protect():
  def __assign__(self, value):
    raise Exception("This is an ex-parrot")

var = Protect()  # once assigned...
var = 1          # this should raise Exception()

这可行吗?这是疯了吗?我应该吃药吗?


2
使用案例:人们将使用我的服务API编写小脚本,我想防止他们更改内部数据并将此更改传播到下一个脚本。 - Caruccio
7
Python明确避免承诺阻止恶意或无知的程序员访问。其他语言允许您避免由于无知而导致的一些程序员错误,但人们有一种神奇的能力可以绕过这些错误。 - msw
你可以使用 exec in d 来执行这段代码,其中 d 是某个字典。如果代码在模块级别上,每个赋值都应该被发送回字典。你可以在执行后恢复你的值/检查值是否改变,或者拦截字典赋值,即用另一个对象替换变量的字典。 - Ant6n
哦不,所以在模块级别上模拟VBA行为,如ScreenUpdating = False是不可能的。 - Winand
你可以使用模块的 __all__ 属性 来防止私有数据被导出,这是 Python 标准库常用的方法。 - Ben
我们能将此推广至全局赋值操作吗?比如 x=1x=2 会报错。 - Poojan
13个回答

100

你描述的方式绝对不可能。给一个名字赋值是Python的基本特性,没有提供任何钩子来改变它的行为。

然而,通过覆盖.__setattr__()方法可以控制对类实例成员的赋值,这一点是可以实现的。

class MyClass(object):
    def __init__(self, x):
        self.x = x
        self._locked = True
    def __setattr__(self, name, value):
        if self.__dict__.get("_locked", False) and name == "x":
            raise AttributeError("MyClass does not allow assignment to .x member")
        self.__dict__[name] = value

>>> m = MyClass(3)
>>> m.x
3
>>> m.x = 4
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 7, in __setattr__
AttributeError: MyClass does not allow assignment to .x member

请注意,这里有一个成员变量_locked,它控制了是否允许赋值。您可以解锁它以更新该值。


6
在使用@property装饰器时,如果只有getter方法而没有setter方法,可以类似地实现伪重载赋值的效果。 - jtpereyda
4
getattr(self, "_locked", None) 翻译为中文是 self.__dict__.get("_locked") 的替代表达方式,意思相同,但更加通俗易懂。 - Vedran Šego
@VedranŠego 我采纳了你的建议,但是使用了 False 而不是 None。现在如果有人删除了 _locked 成员变量,.get() 调用将不会引发异常。 - steveha
1
@steveha,这段代码对你来说是否真的引发了异常?get默认返回None,而getattr则会引发异常。 - Vedran Šego
2
啊,不好意思,我没看到它引发了异常。我不知怎么忽略了您建议使用 getattr() 而不是 .__dict__.get()。我想使用 getattr() 更好,那是它的用途。 - steveha

33
不,因为赋值是一种语言内在特性,它没有修改钩子。

6
放心,Python 4.x不会出现这种情况。 - Sven Marnach
8
我现在很想写一篇PEP来说明如何继承和替换当前作用域。 - zigg

11

我认为这是不可能的。在我看来,将一个变量赋值给另外一个变量并不会对先前所指向的对象产生影响:只是该变量现在“指向”了另一个对象。

In [3]: class My():
   ...:     def __init__(self, id):
   ...:         self.id=id
   ...: 

In [4]: a = My(1)

In [5]: b = a

In [6]: a = 1

In [7]: b
Out[7]: <__main__.My instance at 0xb689d14c>

In [8]: b.id
Out[8]: 1 # the object is unchanged!

然而,你可以通过创建一个具有 __setitem__()__setattr__() 方法并引发异常的包装对象来模拟所需的行为,并将“不可更改”的内容放在其中。


10

在模块内部,通过一些神秘的技巧,这是完全可能的。

import sys
tst = sys.modules['tst']

class Protect():
  def __assign__(self, value):
    raise Exception("This is an ex-parrot")

var = Protect()  # once assigned...

Module = type(tst)
class ProtectedModule(Module):
  def __setattr__(self, attr, val):
    exists = getattr(self, attr, None)
    if exists is not None and hasattr(exists, '__assign__'):
      exists.__assign__(val)
    super().__setattr__(attr, val)

tst.__class__ = ProtectedModule

上面的例子假设代码存储在名为 tst 的模块中。您可以在repl中通过将tst更改为__main__来执行此操作。

如果要保护通过本地模块的访问,请通过tst.var = newval进行所有写入。


我不确定我的Python版本/实现是否有所不同,但对我来说,只有在尝试从受保护的模块外部访问变量时才起作用;即,如果我保护模块“tst”并在模块“tst”内两次将Protect()分配给名为“var”的变量,则不会引发任何异常。这符合文档说明直接赋值直接使用不可覆盖的全局__dict__。 - mutableVoid
1
我不记得我用哪个版本的 Python 进行了测试。当时,我惊讶于它保护了变量免受本地更改的影响,但现在我无法复制它。值得注意的是,'tst.var = 5' 将会引发异常,但 'var = 5' 不会。 - Perkins

8
使用顶级命名空间,这是不可能的。当你运行时,
var = 1

它在全局字典中存储键var和值1。大致相当于调用globals().__setitem__('var', 1)。问题在于,您无法替换正在运行的脚本中的全局字典(可能可以通过操纵堆栈来实现,但这不是一个好主意)。但是,您可以在次要命名空间中执行代码,并为其全局变量提供自定义字典。

class myglobals(dict):
    def __setitem__(self, key, value):
        if key=='val':
            raise TypeError()
        dict.__setitem__(self, key, value)

myg = myglobals()
dict.__setitem__(myg, 'val', 'protected')

import code
code.InteractiveConsole(locals=myg).interact()

这将启动一个REPL,几乎正常运行,但拒绝任何尝试设置变量val的操作。你也可以使用execfile(filename, myg)。请注意,这不能防止恶意代码。


1
这是黑魔法!我完全预料到只会找到一堆人建议使用显式对象和重写setattr,没有想过用自定义对象覆盖全局和本地变量,哇。不过这一定会让PyPy哭的。 - Joseph Garvin
@Gary。#1) 对我来说,这就像是代码异味。#2) 只需在您的驱动程序脚本开头运行此处显示的语句即可。 - Mad Physicist
@mad-physicist 在变量赋值或某些变量值活动(如更改事件)时跨模块(多个模块/文件模块)运行事件。曾经尝试过类似 https://github.com/python-lang-codes/strongtypes 的东西,但在 https://github.com/ganeshkbhat/peps/blob/master/pep-9999.rst 被拒绝后就停止了。之后也尝试过操作 AST,但由于目标更改事件从未被捕获,所以并没有完全成功。但后来看到在 3.8v 中发布了 https://docs.python.org/3/library/audit_events.html,这在一定程度上解决了这个问题。 - Gary
@Gary 听起来你可以轻松地编写一个小类来完成这个任务,或者只需在某个属性中使用一个.x。虽然你必须在访问时多写一个.x,但是这种方式的代码易于阅读和维护。修改AST会使你的代码不可移植。 - Mad Physicist
1
@Gary。你可以对你的模块进行子类化。这里有一个例子:https://dev59.com/MFLTa4cB1Zd3GeqPcKGO - Mad Physicist
显示剩余4条评论

6

我可能会在Python地狱中失落,不过人生没有点乐趣怎么行呢。


重要免责声明:

  • 我只是出于娱乐提供此示例
  • 我100%确定自己并不理解这个很好
  • 从任何意义上说,这都可能不安全
  • 我认为这不切实际
  • 我认为这不是一个好主意
  • 我甚至不想认真尝试去实现它
  • 这对jupyter(可能也适用于ipython)不起作用*

也许你不能重载赋值运算符,但是(至少使用Python ~3.9),你可以在顶层命名空间中实现你想要的东西。要为所有情况正确地做到这一点可能很难,但是通过黑客方式操作audithook,以下是一个小例子:

import sys
import ast
import inspect
import dis
import types


def hook(name, tup):
    if name == "exec" and tup:
        if tup and isinstance(tup[0], types.CodeType):
            # Probably only works for my example
            code = tup[0]
            
            # We want to parse that code and find if it "stores" a variable.
            # The ops for the example code would look something like this:
            #   ['LOAD_CONST', '<0>', 'STORE_NAME', '<0>', 
            #    'LOAD_CONST', 'POP_TOP', 'RETURN_VALUE', '<0>'] 
            store_instruction_arg = None
            instructions = [dis.opname[op] for op in code.co_code]
            
            # Track the index so we can find the '<NUM>' index into the names
            for i, instruction in enumerate(instructions):
                # You might need to implement more logic here
                # or catch more cases
                if instruction == "STORE_NAME":
                    
                    # store_instruction_arg in our case is 0.
                    # This might be the wrong way to parse get this value,
                    # but oh well.
                    store_instruction_arg = code.co_code[i + 1]
                    break
            
            if store_instruction_arg is not None:
                # code.co_names here is:  ('a',)
                var_name = code.co_names[store_instruction_arg]
                
                # Check if the variable name has been previously defined.
                # Will this work inside a function? a class? another
                # module? Well... :D 
                if var_name in globals():
                    raise Exception("Cannot re-assign variable")


# Magic
sys.addaudithook(hook)

以下是一个例子:

>>> a = "123"
>>> a = 123
Traceback (most recent call last):
  File "<stdin>", line 21, in hook
Exception: Cannot re-assign variable

>>> a
'123'

*对于Jupyter,我找到了一种看起来更干净的方法,因为我解析了AST而不是代码对象:

import sys
import ast


def hook(name, tup):
    if name == "compile" and tup:
        ast_mod = tup[0]
        if isinstance(ast_mod, ast.Module):
            assign_token = None
            for token in ast_mod.body:
                if isinstance(token, ast.Assign):
                    target, value = token.targets[0], token.value
                    var_name = target.id
                    
                    if var_name in globals():
                        raise Exception("Can't re-assign variable")
    
sys.addaudithook(hook)

当我运行Python shell时,如何将其设置为默认值?我尝试使用相同的全局覆盖,但不确定是否能够在运行代码而不是在shell中运行Python可执行文件来运行上述addautdithook。有什么想法可以使审计钩子成为默认设置吗? - Gary
看了一下这个 https://docs.python.org/3/c-api/sys.html#c.PySys_AddAuditHook 和 https://docs.python.org/3/library/audit_events.html,这个审计钩子绝对是一个很棒的改变!它可以通过一些小的调整来解决我的问题,但是我是否可以通过默认配置(Python环境默认配置)完全支持通过命令行或第三方调用运行Python可执行文件时使用这些钩子呢?也许我漏掉了什么?可能有另一个PEP可以被拿来参考并提出这个问题。或者真的需要吗? - Gary
1
我相信这只能在Python REPL上运行exec的每一行代码才有效,但是在运行python file.py时则不然。也许“正确”的方法是像你试图进入C领域那样做些什么,但我对此不熟悉。另一种方法可能是依赖于挂钩导入系统而不是审计挂钩:例如,您可以读取包含您的魔术代码的文件并以某种方式解析它。那可能很有趣。 - Kostas Mouratidis
是的。这可能是一种方法。但这不会以任何方式影响 shell 或命令。也许我可以在每个文件中管理相同的钩子。但这似乎有些冗余。 - Gary

4

不,没有这个功能

想一想,在你的例子中,你重新绑定了name变量到一个新值。 你并没有实际改变Protect的实例。

如果你想重新绑定的name实际上是某个实体的属性,例如 myobj.var ,那么你可以防止给该实体的属性/属性赋值。 但我猜这不是你在例子中想要的。


2
就快了!我试图重载模块的__dict__.__setattr__,但是module.__dict__本身是只读的。另外,type(mymodule) == <type 'module'>,它不可实例化。 - Caruccio

4

是的,这是可能的,您可以通过修改ast来处理__assign__

pip install assign

测试:

class T():
    def __assign__(self, v):
        print('called with %s' % v)
b = T()
c = b

您将获得

>>> import magic
>>> import test
called with c

该项目位于 https://github.com/RyanKung/assign 而且还有更简单的要点:https://gist.github.com/RyanKung/4830d6c8474e6bcefa4edd13f122b4df


有些事情我不明白... 应该是 print('called with %s' % self) 吧? - zezollo
2
有几件事我不明白:1)为什么字符串'c'会成为__assign__方法的v参数?这个例子到底展示了什么?它让我感到困惑。2)这在什么情况下会有用?3)这与问题有什么关系?为了对应于问题中编写的代码,难道不应该写成b = c而不是c = b吗? - HelloGoodbye
OP感兴趣的是取消名称绑定的情况,而不是绑定名称的情况。 - Mad Physicist

4
通常,我发现最好的方法是重写__ilshift__作为setter和__rlshift__作为getter,并由property装饰器复制。 它几乎是最后一个被解决的运算符(| & ^),逻辑运算符更低。 很少使用(__lrshift__更少,但可以考虑)。
在使用PyPi assign包时,只能控制前向赋值,因此运算符的实际 "强度" 较低。 PyPi assign包示例:
class Test:

    def __init__(self, val, name):
        self._val = val
        self._name = name
        self.named = False

    def __assign__(self, other):
        if hasattr(other, 'val'):
            other = other.val
        self.set(other)
        return self

    def __rassign__(self, other):
        return self.get()

    def set(self, val):
        self._val = val

    def get(self):
        if self.named:
            return self._name
        return self._val

    @property
    def val(self):
        return self._val

x = Test(1, 'x')
y = Test(2, 'y')

print('x.val =', x.val)
print('y.val =', y.val)

x = y
print('x.val =', x.val)
z: int = None
z = x
print('z =', z)
x = 3
y = x
print('y.val =', y.val)
y.val = 4

输出:

x.val = 1
y.val = 2
x.val = 2
z = <__main__.Test object at 0x0000029209DFD978>
Traceback (most recent call last):
  File "E:\packages\pyksp\pyksp\compiler2\simple_test2.py", line 44, in <module>
    print('y.val =', y.val)
AttributeError: 'int' object has no attribute 'val'

同样适用于Shift:
class Test:

    def __init__(self, val, name):
        self._val = val
        self._name = name
        self.named = False

    def __ilshift__(self, other):
        if hasattr(other, 'val'):
            other = other.val
        self.set(other)
        return self

    def __rlshift__(self, other):
        return self.get()

    def set(self, val):
        self._val = val

    def get(self):
        if self.named:
            return self._name
        return self._val

    @property
    def val(self):
        return self._val


x = Test(1, 'x')
y = Test(2, 'y')

print('x.val =', x.val)
print('y.val =', y.val)

x <<= y
print('x.val =', x.val)
z: int = None
z <<= x
print('z =', z)
x <<= 3
y <<= x
print('y.val =', y.val)
y.val = 4

输出:

x.val = 1
y.val = 2
x.val = 2
z = 2
y.val = 3
Traceback (most recent call last):
  File "E:\packages\pyksp\pyksp\compiler2\simple_test.py", line 45, in <module>
    y.val = 4
AttributeError: can't set attribute

所以,在获取属性值时使用<<=运算符是更加视觉清晰的解决方案,不会让用户犯一些反射性错误,例如:
var1.val = 1
var2.val = 2

# if we have to check type of input
var1.val = var2

# but it could be accendently typed worse,
# skipping the type-check:
var1.val = var2.val

# or much more worse:
somevar = var1 + var2
var1 += var2
# sic!
var1 = var2

2
在全局命名空间中,这是不可能的,但您可以利用更高级的Python元编程来防止创建多个Protect对象实例。Singleton模式就是一个很好的例子。
在Singleton的情况下,您将确保一旦实例化,即使重新分配原始变量引用实例,该对象也将持续存在。任何后续实例都只会返回对同一对象的引用。
尽管有此模式,您永远无法阻止全局变量名称本身被重新分配。

一个单例是不够的,因为 var = 1 不会调用单例机制。 - Caruccio
明白了。如果我表达不清楚,我向你道歉。单例模式可以防止对象(例如Protect())被创建多个实例。但无法保护最初分配的名称(例如var)。 - jathanism
@Caruccio。虽然无关紧要,但99%的时候,在CPython中,数字1会表现为单例。 - Mad Physicist

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接