Python面向对象编程02-类与实例的属性

实例的属性

在上一节中,已经详细解释了实例的属性(attribute)。实例的属性可以使用 instance.attribute 来访问并赋值。假设有一个类 Stack ,它的属性可以有栈长度、栈顶位置、栈段,那么该类的实现可以是:

class Stack:
    def __init__(self, length):
        self.length = length
        self.body = [0] * length
        self.top = 0
    def push(self, value):
        if self.top >= self.length:
            raise OverflowError('Not enough space to push')
        self.body[self.top] = value
        self.top += 1
    def pop(self):
        if self.top < 0:
            raise OverflowError('Stack is empty')
        elem = self.body[self.top]
        self.top -= 1
        return elem

这里为其编写了一个初始化方法用于构造栈所需的属性,以及两个普通方法用于入栈和出栈。这里属性还充当着在多个方法间传递数据的角色,这样不用使用参数和返回值,就可以在一个方法执行完后,将栈顶位置的变化实时作用在另一个方法上。

受保护属性和私有属性

属性不但可以用于表示一个类具有哪些特征,而且可以保存这些特征的变化。然而有些时候,并不希望属性直接被访问到,因为多个属性间往往具有一定的关联,对单个属性的修改可能会导致这个关联被破坏掉。

例如,以上实现的 Stack 类,栈元素和栈顶之间具有一定关联,一般只期望通过提供的 .push().pop() 方法一并修改它们,如果只修改栈顶位置的话,有些栈元素可能再也无法访问了。

针对上述问题,Python的一个默认约定是使用下划线 _ 开头的名称来作为实例属性,来表示受保护属性。那么栈的初始化可以改成这样:

class Stack:
    def __init__(self, length):
        self.length = length
        self.body = [0] * length
        self._top = 0

这样,属性 self._top 就是一个受保护属性。在一般情况下,不应该去访问它并且修改这个属性。

不过需要注意的是,受保护属性是 Python 编程时的一个约定,它并没有阻止访问 ._top 这个属性,只是一般情况下,如果不是刻意,编程时很少会特地访问这些以下划线开头的属性,从而减少受保护属性被意外修改的可能性。


实际上,Python 还可以创建实例的私有属性。相比受保护属性,私有属性进一步降低了属性被意外修改的误操作。

许多编程语言都提供了 private关键字。而在 Python 中,私有属性是以两个下划线 __ 开头的实例属性,例如 self.__length 。这样一来,在实例化一个类后,便不再能够访问该属性。如果想要强行访问如 stack.__length ,会引起 AttributeError ,错误内容大概是没有这个属性。

通过使用私有属性,可以很好地将一个属性隐藏起来。例如以上的 Stack 类,正常情况下栈段和栈的长度都是不应该通过直接访问的形式去修改的,那么就可以将这两个属性变成私有属性:

class Stack:
    def __init__(self, length):
        self.__length = length
        self.__body = [0] * length
        self._top = 0
    def push(self, value):
        if self._top >= self.__length:
            raise OverflowError('Not enough space to push')
        self.__body[self._top] = value
        self._top += 1

以上说的实例的私有属性无法访问,是针对类定义的外部,操作实例时而言的。如果在类的定义内,知道自己在做什么,当然可以继续使用 self.__length

因此,一个常见的做法是为类编写一个方法,提供查看私有属性的接口,例如:

class Stack:
    def get_length(self):
        return self.__length

这样,使用该类时只能利用提供的确定、合理的的接口获取类的属性,或者对类进行一些操作。在本文的最后,还会介绍一个 Python 给出的更高级、更优雅的解决方式。


实际上,私有属性和受保护属性一样,也没有真正阻止通过实例来修改它的属性。如果使用内置的 dir() 函数来查看一个实例支持的一些属性和方法,结果为:

>>> stack = Stack(10) >>> dir(stack) ['_Stack__body', '_Stack__length', '__class__', ..., '__weakref__', '_top', 'get_length', 'pop', 'push']

在结果返回的列表中,没有找到什么名为 .__length 之类的属性。但是注意,实例却有两个属性 ._Stack__body._Stack__length 。试着访问一下它们:

>>> stack._Stack__body [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]

原来,实例的私有属性并不是真正的私有,只不过将一个实例的属性命名以双下划线开头,在结束类的定义后,Python就会将该属性的名称前面再加上 _ClassName ,即一个下划线和对应类的名称。如此一来,要访问实例的属性难度变得更大了,也更不易出错了。

尽管这样做已经可以避免在实际编程中意外修改实例的私有属性,并允许刻意去修改这些私有属性。不过仍然有一些细节需要注意:首先,尽量不要将实例的其它属性命名为私有属性加上保护前缀后的属性。尽管在类定义中不会有什么影响,但这可能造成实例化一个对象后的命名冲突。其次,以两个下划线开头结尾的双下属性并不受该规则影响,但是上一节也说过了,它们可能是一个实例的特殊属性,对它们的任意赋值及修改可能会打乱类的运行,造成意想不到的问题。

类属性和实例属性

实例属性是每一个实例所独自拥有的、不同实例间通过实例属性的区别而可能产生一定的差异。在上文中,已经见过了许多实例属性,对于每一个 Stack 实例,它们的大小、栈顶位置、包含的元素都不尽相同,因此需要通过属性予以区分。如果该类还没有一个实例,那么这些属性只是一个笼统的概念,无法进行具体的讨论,也就无法仅通过类来访问实例属性并获取值。

与实例属性相对的概念是类属性。在类中,方法之外创建的变量称为类属性。例如,以下为 Point 类创建了一个类属性:

class Point:
    dimensions = 3
    def __init__(self, x, y, z):
        self.x, self.y, self.z = x, y, z

类属性的特点是,所有类的实例化对象都同时共享类属性。也就是说,类变量在所有实例化对象中是共有的。这也很好理解,对于定义的类 Point 来说,它是三维的点,因此它所有的实例 .dimensions 都是 3 ,这是无法更改的事实,因此所有该类的实例都必须具有这一个确定的属性。

类属性与实例属性的一个区别是类属性可以由类直接访问。当创建了一个实例对象时,通过实例对象也可以访问类的属性,例如:

>>> Point.dimensions 3 >>> p01 = Point(3, 4, 5) >>> p01.dimensions 3

类属性是一个确定的定义,只要实例是属于该类的,那么它们都必须和类一样具有这个共同的属性。

事实上,在实例内部定义的方法,同样属于这个类的类属性。因此可以使用匿名函数与赋值的方式来定义一个方法,也是完全可以的:

class Point:
    dimensions = 3
    get_coordinates = lambda self: (self.x, self.y, self.z)

在类内部定义一个方法,可以理解为定义一个函数后,将其作为类属性放在类内部。

通过类和实例都可以访问类属性,类属性也可以通过赋值语句进行修改。但是如果是通过类修改的类属性,会影响到所有实例来访问这个类属性。而通过实例修改这个类属性,则不会影响到类和实例中访问的类属性。例如:

p02 = Point(3, 7, 9)
p01.dimensions = 'three'  # 通过实例修改类属性
print(p01.dimensions, p02.dimensions, Point.dimensions)
Point.dimensions = 4      # 通过类修改类属性
print(p01.dimensions, p02.dimensions, Point.dimensions)

结果为:

python -u demo.py three 3 3 three 4 4

注意到当通过类修改类属性后,第一个实例的属性并没有被修改,但是第二个实例的属性却被修改了。

关于这一点,可以通过内置的 id() 函数来查看对应的类属性的身份变化:

print(id(p01.dimensions), id(p02.dimensions), id(Point.dimensions))
p01.dimensions = 'three'  # 通过实例修改类属性
print(id(p01.dimensions), id(p02.dimensions), id(Point.dimensions))
Point.dimensions = 4      # 通过类修改类属性
print(id(p01.dimensions), id(p02.dimensions), id(Point.dimensions))

结果为:

python -u demo.py 15757472 15757472 15757472 26266752 15757472 15757472 26266752 15757488 15757488

观察结果不难发现,当实例化一个类后,通过实例访问的属性仅仅是对类属性的一个引用,因此通过类修改类属性会引起实例访问类属性的变化。而通过实例修改类属性后,实际上是创建了一个新的实例属性,因此通过类修改类属性,当然不会影响这个实例属性。

要解释以上原因的底层原理还为时过早。在后续介绍描述符这一概念时,才会明白属性查找的原理。

使用类的property

为什么需要用property

在前面章节中,一直使用 instance.attribute 的方式来访问实例的属性,但是有些情况下希望属性应该是隐藏的,只允许通过提供的接口方法来间接实现对实例属性的访问和修改。

例如,以下实现了一个类 Color ,用来表示计算机存储的颜色数据:

class Color:
    def __init__(self, red, green, blue):
        self.__r, self.__g, self.__b = red, green, blue
        self._hsv_update()
    def _hsv_update(self):
        _max = max(self.__r, self.__g, self.__b)
        _min = min(self.__r, self.__g, self.__b)
        self.__v = max(self.__r, self.__g, self.__b)
        self.__s = (_max - _min) / _max
        if self.__r == _max:
            self.__h = (self.__g - self.__b) / (_max - _min) * 60
        if self.__g == _max:
            self.__h = 120 + (self.__b - self.__r) / (_max - _min) * 60
        if self.__b == _max:
            self.__h = 240 + (self.__r - self.__g) / (_max - _min) * 60
        if self.__h < 0:
            H += 360

由于计算机存储的色彩空间未必是 RGB ,这里也提供了 HSV 色彩空间的支持,并且每次在更新了 RGB 色值后,会将这个更新实时反应到 HSV 上。因此这里将 RGB 属性都设置为私有属性,为了就是防止 RGB 色值被意外修改而造成两种色彩空间不同步。

如果确实需要对颜色做一些调整,或者获取当前的 RGB 色值,可以编写一些 .get().set() 等接口方法,在设置颜色时确保可以将其同步到 HSV 色彩空间中:

class Color:
    def get_red(self):
        return self.__r
    def set_red(self, red):
        if not 0 <= red <= 255:
            raise ValueError('red must be between 0 and 255')
        self.__r = red
        self._hsv_update()

对于其它两种基本颜色属性,也可以编写类似函数。

以上是比较传统的想法,通过将实例属性设置成私有属性,然后提供 .get().set() 等接口方法来获取或设置属性,并在设置属性时进行一些合适的检查。但实际上,Python 提供了一些内置的工具,可以更为方便地操作实例的属性。

property类

Python 中,一个 property 类可以将众多 .get().set() 等接口方法封装成一个属性,这样通过对该属性访问和赋值等操作,实际上就是在调用这些接口方法。property 类的完整初始化函数形式为:

property(fget=None, fset=None, fdel=None, doc=None)

通过实例化该类,可以为该类添加一个特殊的 property 实例属性。在它的参数中:

  • fget 是获取属性值时调用的接口方法
  • fset 是设置属性值时调用的接口方法
  • fdel 是删除属性时调用的接口方法
  • doc 则用来创建属性的 docstring

例如,如果采用 property 属性对 color 类做以下封装:

class Color:
    def del_red(self):
        self.__r = 0
        self._hsv_update()
    # ...
    red = property(get_red, set_red, del_red, "Red component of color")

如果 background 是一个 Color 类的实例,那么访问 background.red 会调用它的 .get_red() 方法;background.red = value 会调用它的 .set_red() 方法,并将 value 作为第二个参数;而 del background.red 会调用它的 .del_red() 方法。

如果 doc 不为 None ,那么可以在类内通过 red.__doc__ 来访问它的 docstring(注意,不能通过 background.red.__doc__ 的方式访问它的 docstring ,因为前半部分会先运算而得到红色数值)。否则,property 会将 fget 函数的 docstring 拷贝为 background 的 docstring 。

需要特别注意的是,在 .get_red() 方法中,一定要使用私有属性,而不能直接使用 property 封装的实例属性;否则,当在 .get_red() 内访问 self.red 的话,又会调用该方法,造成无限递归引起程序终止。

@property装饰器

如果程序中不希望支持修改或删除这些属性,那么可以在初始化方法中将对应的接口方法设置为 None 。除了使用 property 类,还有一种更清晰的方法是使用 @property 装饰器,它用来装饰一个类的方法,将该方法名变成同名的属性。如果对装饰器这一概念比较陌生,可以阅读这篇文章

下面以 Color 类的绿色属性为例介绍该装饰器的使用方法。@property 装饰器可以将类方法变成同名属性的 .get() 接口。这样一来,便可以通过 instance.attribute 的方式来访问它了:

class Color:
    @property
    def green(self):
        """Green component of color"""
        return self.__g

访问 @property 包装后的 .attribute 属性,相当于访问属性的 .get() 方法。除此之外,.property 属性还支持 .set().delete() 方法,它们需要使用 @attribute.setter 装饰器和 @attribute.deleter 装饰器。例如:

class Color:
    # ...
    @green.setter
    def green(self, green):
        if not 0 <= green <= 255:
            raise ValueError('green must be between 0 and 255')
        self.__g = green
        self._hsv_update()

当为属性 background.green 赋值时,会调用它的 @green.setter 装饰器装饰的方法。类似地可以编写一个 @green.deleter 装饰器装饰的方法,以被 del 语句删除属性时调用。注意:这些方法名必须和 @property 包装的属性名相同,代表为属性设置新的操作接口。

试着编写一些代码:

background = Color(128, 64, 192)
print(background.red)
print(background.hue)
background.green = 255
print(background.hue)
del background.blue
print(background.hue)

结果为:

$ python -u demo.py 128 270.0 150.23622047244095 89.88235294117646

可以看到对 RGB 色值的更新实时反应在 HSV 上。

本节介绍了类与实例属性的概念,并提出了私有属性的概念,私有属性配合接口方法,可以避免实例的完整性被破坏,使对属性的操作更规范。这也符合面向对象编程中常用的封装思路,即隐藏对象的属性和实现细节,仅对外公开接口,以控制在程序中属性的访问和修改。

通过 property 可以简化接口的操作方式,使代码更优雅。如果仅看 property 的使用方式,可能会觉得比较费解,比如这样定义的明明像一个类属性,为什么得到的却是一个实例属性?要解释以上原因的底层原理还为时过早,同样在后续介绍描述符时,才会介绍其根本原因,到时候将会对类与实例的属性有更深刻的认知。

参考资料/延伸阅读

https://docs.python.org/3/library/functions.html#property

Python3 官方文档对 property 的介绍。

本文使用的 RGB to HSV 转换算法来自百度百科 https://baike.baidu.com/item/HSV/547122

京ICP备2021034974号
contact me by hello@frozencandles.fun