65.9K
CodeProject 正在变化。 阅读更多。
Home

Python 只读属性:完整解决方案

2018年1月28日

MIT

8分钟阅读

viewsIcon

26929

可靠的解决方案奏效了:它不依赖任何命名约定,同时适用于 Python 2 和 3,并提供了清晰简洁的使用语法。

目录

引言

此解决方案基于 rIZenAShesGitHub 上发现的一个小型代码示例,该示例展示了一些非常有趣的想法。

至于代码,我发现它远不能令人满意。首先,它只与 Python 2 兼容,不与 Python 3 兼容。更糟糕的是,它基于某些命名约定。要暴露的属性由前导下划线标记,该下划线会被元类删除,以生成暴露的只读属性。当前解决方案与 Python 的两个版本都兼容,并提供了清晰简洁的语法。

那么,有什么大不了的呢?

实现只读属性相当容易

class Meta(type):
    @property
    def RO(self):
        return 13

class DefinitionSet(Meta(str(), (), {})):
    greetings = "Hello!"
    myNameFormat = "My name is {}."
    durationSeconds = 3.5
    color = { "opacity": 0.7, "wavelength": 400 }
    @property
    def RO(self):
        return 14
    def __init__(self):
        self.greetings = "Hello again!"
        self.myNameFormat = "Let me introduce myself. My name is {}."
        self.durationSeconds = 3.6
        self.color = { "opacity": 0.8, "wavelength": 410 }

instance = DefinitionSet()
# instance.RO and DefinitionSet.RO are two different
# read-only attributes

在此代码示例中,instance.RO 作为实例属性,而 DefinitionSet.RO 作为类属性;它们被引入为只读属性

请注意开发中的某些不便之处:虽然在使用中,此类属性被用作“常规”属性的同等项(例如,instance.ROinstance.color),但它是在更高级别设置的,即对象类型的级别。对于 instance,这是实例类型 type(instance) == DefinitionSet;对于 DefinitionSet,这是它的元类 type(DefinitionSet) == Meta。(通过继承设置 DefinitionSet 的元类的方法不那么明显,这里展示出来是为了显示 Python 2 和 3 的等效代码,见下文。)

类属性只读属性的定义比实例属性的定义看起来要复杂一些:对于实例,描述至少可以(几乎)放在一个地方(比较 DefinitionSet.RO__init__ 中的 self.greetings)。对于类属性,属性定义应放在一个单独的类(我们示例中的 Meta)中。

现在,这个问题通过一个小的设备,即 @property装饰器来缓解,该装饰器可以被视为一种语法糖。如果我们想从头开始ab ovo,我们将展示更基本的描述符用法,基于 __get__,这在 Python 2Python 3 的文档中有描述。

那么,我们能否创建比这更甜、更短、更清晰简洁的语法糖?它有实际意义吗?

答案取决于我们对类属性的用法,因为将它们转换为只读属性看起来更令人困惑,也不那么清晰。

为什么需要类属性?

类属性有许多用途,但我想通过一个简单的用例来说明它们的重要性:定义集。假设我们需要定义一些字符串和整数常量。将它们全部放在一个地方是个好主意,以避免在任何其他地方使用立即定义的魔术数字魔术字符串

让我们暂时忘记只读属性,只比较两个选项

# using class attributes:
class DefinitionSet:
    greetings = "Hello!"
    myNameFormat = "My name is {}."
    durationSeconds = 3.5
    color = { "opacity": 0.7, "wavelength": 400 }

#...
print (DefinitionSet.durationSeconds)

# using instance attributes:
class DefinitionSet:
    def __init__(self):
        self.greetings = "Hello!"
        self.myNameFormat = "My name is {}."
        self.durationSeconds = 3.5
        self.color = { "opacity": 0.7, "wavelength": 400 }
definitionSet = DefinitionSet()

#...
print (definitionSet.durationSeconds)

显而易见,使用类属性的选项更短、更方便。通常,只有在我们有多于一个实例时才需要实例,但在这种情况下,更无聊的部分是作为 __init__ 参数传递值。对于一组单一的定义来说,这将完全没有意义。

类属性的解决方案:用法

首先,让我们看看如何使用它

class Foo(ReadonlyBase):
    bar = 100
    test = Readonly.Attribute(13)

print("Foo.bar: " + str(Foo.bar))
Foo.bar += 1
print("Modified Foo.bar: " + str(Foo.bar))
print("Foo.test: " + str(Foo.test))
try:
    Foo.test = Foo.test + 1 # will raise exception
except Exception:
    print ("Cannot set attribute Foo.test")

在这里,属性 test 仅通过使用 Readonly.Attribute 进行赋值来标记;所需任何类型的常量值被移至实际的调用参数。对象 Attribute 是类 Readonly 的内部类;整行是对其构造函数的调用和赋值。

这里有一个想法:整个技巧由元类完成:如果属性被分配给 Readonly.Attribute 对象,则类对象的实例化会删除该属性,并创建一个由另一个元类公开的相应只读属性。听起来可能很棘手,但……它确实很棘手。 下面,我们可以看到它是如何工作的。

事实上,不必使用 ReadonlyBase 基类。它在此代码示例中显示,因为 Python 2 和 Python 3 的语法不同。类 Foo 可以直接设置其元类,而无需任何基类。唯一的问题是语法不同。让我们考虑一下这个令人不快的 Python 问题及其解决方法。

Python 2 和 3 在演示中的统一

上面显示的用法示例缺少 ReadonlyBase 类的定义。没有这个类,Foo 类可以直接从用作其元类的 Readonly 类创建,使用以下语法

# Python 2.*.*:
class Foo(object, metaclass = Readonly):
    # ...
# Python 3.*.*:    
class Foo(object):
    __metaclass__ = Readonly
    # ...

或者,也可以用同样的方式创建基类 ReadonlyBase。相反,“demo.py”文件使用元编程方法创建等效的类对象

ReadonlyBase = Readonly(str(), (), {})

这段代码与 Python 的两个版本都兼容。要理解它是如何工作的,只需知道元类就是从类 type 派生(直接或间接)的类。调用其构造函数会创建一个对象,该对象是一个类:它具有类的所有属性,可以用作类,并且可能根据第二个参数(bases)用作元类。

此时,用法已解释。现在,该展示元类 Readonly 如何将由赋值标记的类属性转换为只读属性了。

它是如何工作的?

这就是完整的解决方案

class Readonly(type):

    class Attribute(object):
        def __init__(self, value):
            self.value = value
    
    def __new__(metaclass, classname, bases, classdict):
        class NewMetaclass(metaclass):
            attributeContainer = {}
        def getAttrFromMetaclass(attr):
            return lambda cls: type(cls).attributeContainer[attr]
        clone = dict(classdict)
        for name, value in clone.items():
            if not isinstance(value, metaclass.Attribute):
                continue;
            getattr(NewMetaclass, DefinitionSet.attributeContainerName)[name] = value.value
            aProperty = property(getAttrFromMetaclass(name))
            setattr(NewMetaclass, name, aProperty)
            classdict[name] = aProperty
            classdict.pop(name, None)               
        return type.__new__(NewMetaclass, classname, bases, classdict)

容易展示但难解释。

首先,对于所有使用 Readonly 作为元类的类,此元类仅用于类对象的实例化。在实例化时,类对象使用一个名为 NewMetaclass 的不同元类创建,该元类是每个类实例的独立实例。它被称为“New”,因为它最终用于调用 type.__new__(NewMetaclass, classname, bases, classdict)

NewMetaclass 的每个实例都不同。首先,它用作正在初始化的类所使用的 Readonly.Attribute 所有实例的容器。其次,它用作一些属性的容器,每个属性的名称与原始类属性完全相同,将被重构为只读属性。

遍历类的原始属性集时,会创建 Readonly.Attribute 实例并将其放入字典 NewMetaclass.attributeContainer 中。对于每个此类属性,使用 property() 构造函数创建属性对象。对于每个不同的属性名,此类属性会使用基于名称生成的 lambda 表达式进行初始化,该表达式返回从 attributeContainer 检索的值。

在这些操作期间,传递给 type._new_ 的原始类字典被修改,以删除原始的“想要成为只读”的类属性。在遍历之前,会克隆该字典,否则我们可能会遇到一个异常(在 Python 3 中),该异常是由尝试修改正在迭代的字典引起的。

这还不够吗?不。我们可以再向前迈出一大步。

实例属性该怎么办?

是否可以使用相同的机制来实现实例属性?

也许如果我们只需要实例属性而不是类属性,我们就不会费心了。但是,当 Readonly.Attribute 的使用机制已经可用时,拥有类属性和实例属性更简洁统一的外观将更自然。

class Foo(ReadonlyBase): # or make Readonly a metaclass of Foo, see above
    bar = 100
    test = Readonly.Attribute(13)
    def __init__(self):
        self.a = 1
        self.b = Readonly.Attribute(3.14159)

那么,如何对实例属性(例如 b)实现类似的只读效果?下面将展示这一点。

通用解决方案

令人惊讶的是,将类似的技术应用于实例属性比类属性要棘手得多。

主要问题在于处理类的多个实例。属性的实现,无论是否只读,都需要修改实例类。这很容易在元类的 __new__ 方法中完成,但这只会对该类的一个实例化起作用。在尝试创建第二个实例时,将 Readonly.Attribute 分配给同一属性的构造函数将失败,因为已修改的类已经提供了该属性的只读功能。因此,我们陷入了需要为每个实例创建单独类的境地。

真正的技巧是将一个钩子注入类构造函数,这通过元类的 __call__ 方法体内的 type.__call__ 调用来实现。

当此调用创建 instance 时,我们需要类动态创建的另一个实例。这个新实例 newInstance 是从动态创建的类 NewClass 创建的,而无需构造函数。现在,使用两个实例和两个类(新旧类),我们可以操纵实例属性,将它们分配给 newInstance — 用于读写实例属性,以及 NewClass — 用于替换实例属性的只读属性。

class DefinitionSet:
    attributeContainerName = "."

class Readonly(type):

    class Attribute(object):
        def __init__(self, value):
            self.value = value

    @classmethod
    def Base(cls): # base class with access control of class attribute
        return Readonly(str(), (), {})
    
    def __new__(metaclass, className, bases, classDictionary):
        def getAttrFromClass(attr):
            return lambda cls: getattr(type(cls), DefinitionSet.attributeContainerName)[attr]
        class NewMetaclass(metaclass):
            setattr(metaclass, DefinitionSet.attributeContainerName, {})
            def __call__(cls, *args, **kwargs):
                instance = type.__call__(cls, *args, **kwargs)
                newClass = metaclass(cls.__name__, cls.__bases__, {})
                newInstance = type.__call__(newClass)
                setattr(newClass, DefinitionSet.attributeContainerName, {})
                names = dir(instance)
                for name in names:
                    if hasattr(cls, name):
                        continue
                    value = getattr(instance, name)
                    if isinstance(value, metaclass.Attribute):
                        if hasattr(newInstance, name):
                            delattr(newInstance, name)
                        getattr(
                            newClass,
                            DefinitionSet.attributeContainerName)[name] = value.value
                        aProperty = property(getAttrFromClass(name))
                        setattr(newClass, name, aProperty)
                    else:
                        setattr(newInstance, name, getattr(instance, name))
                return newInstance
        clone = dict(classDictionary)
        for name, value in clone.items():
            if not isinstance(value, metaclass.Attribute):
                continue;
            getattr(NewMetaclass, DefinitionSet.attributeContainerName)[name] = value.value
            aProperty = property(getAttrFromClass(name))
            setattr(NewMetaclass, name, aProperty)
            classDictionary[name] = aProperty
            classDictionary.pop(name, None)               
        return type.__new__(NewMetaclass, className, bases, classDictionary)

请注意,getAttrFromClass 在不同类之间重用,即用于实现实例只读属性的实例类和用于实现类只读属性的元类。但是,替换的机制是不同的。

另一个技巧是“隐藏”存储在类中的实例字典,并将其命名为 DefinitionSet.attributeContainerName。使用这样的名称,该属性不会作为“常规”点表示法操作 instance.attribute = value 的结果出现;它只能通过 getattr/setattr/delattr/hasattr 方法进行操作。这似乎非常重要,因为它有助于避免与基于点表示法的用户属性发生任何可能的冲突,即使用户使用了任意数量下划线的属性名。这样,实现就不依赖于 Python 开发者通常使用的任何命名约定。

版本

v.1.0.0:初始完全功能版本。
v.1.0.1:小修复。
v.2.0.0:稳定版本;演示版本实现了上面解释的 Python 2 和 3 统一
v.3.0.0:将机制大规模地推广到类属性和实例属性。

© . All rights reserved.