Python科普系列——类与方法(上篇)

欢迎来到新的系列,up又开新坑了~~

实际上,Python作为一门易用性见长的语言,看上去简单,却仍然有很多值得一说的内容,因此这个系列会把Python中比较有意思的地方也给科普一遍。而另一方面,关于Python的学习资料在中文互联网上已经随处可见,虽然大都是入门向、实用向的,不过资料覆盖面也已经挺全乎的了。所以这个系列将会着重去讲一些现有中文资料里不常见到的硬核内容,尝试去用另外一个视角去讲解Python,也因此,这个系列更适合有最基本Python使用基础,对基本概念有初步认识的读者。

本文将会着重讲讲关于类的事情,尤其是类的方法。考虑到treevalue系列的第三篇也即将推出,并且也会较多涉及到关于类和方法相关的内容,因此本文和下篇也会有所侧重,主要从原理的角度讲解类和方法的本质,以方便理解。而对于略过的部分,后续也将考虑另开文章进行详细讲解。

对象是如何被构造的

首先,让我们来一块想一个终极问题——对象是怎么来的?这看起来答案显而易见——对象不就是构造函数构造出来的么?但实际上这么说并不准确,要说到Python对象是如何被构造的,就不得不说三个特殊的方法: __new____init____del__

首先 __init__ 应该用过Python的都不陌生,但是另外两个分别是什么就未必了解了。我们来看一个最为直观的例子

class T:
    def __init__(self, x, y):
        print('Initializing T', x, y)
        self.x = x
        self.y = y

    def __new__(cls, *args, **kwargs):
        print('Creating new T', args, kwargs)
        return object.__new__(cls)

    def __del__(self):
        print('Deleting T')


if __name__ == '__main__':
    t = T(1, 2)
    print('t is initialized.')

# Creating new T (1, 2) {}
# Initializing T 1 2
# t is initialized.
# Deleting T

通过这个例子会发现,执行的顺序大致如下图所示

具体来说,分为以下几个阶段:

  • “从无到有”——通过 __new__ 方法,创建一个新的初始对象,并将此模板对象作为 self 传入给后续的 __init__ 方法。
  • “组装配件”——通过 __init__ 方法,基于之前生成的函数初始对象进行装饰(也就是常说的字段赋值)。这一过程类似于工厂模式,并非在创造而是在加工。经过了这一步处理的对象,才算是正式完成了对象的初始化,这一初始化完毕的对象也会传回到调用构造函数之处,作为一个真正的实例参与到业务逻辑中
  • “对象销毁”——当对象的生命周期结束之时,通过 __del__ 方法,处理掉当前对象下于初始化阶段组装的全部“配件”。处理完毕后,该对象将被销毁,对象的生命周期就此终止

也就是说,我们所日常认知的Python对象,其实是经历了__new____init__两个阶段构造出来的实例,也正是这样构造出来的对象,支撑了我们在Python中几乎所有的数据模型及其业务逻辑。

延伸思考1__new____del__ 分别有什么样的应用场景?

延伸思考2:如果需要定义一个类,且需要在任意时刻了解其所有处于活动状态的实例对象并进行查询,应该如何去实现?

欢迎评论区讨论!

类与对象的本质

首先说到Python中的类,关于类及其方法的基本介绍,可以参考Runoob:Python3 面向对象,里面有面向初学者的详细介绍,而对于面向对象的基本编程思想,维基百科上也有比较详细的介绍,此处不作展开。

我们就从类的定义形态开始,讲讲类的本质是什么。首先我们来看一个最简单的类定义

class MyClass:
    cvalue = 233
    
    def __init__(self, x):
        self.__x = x

    def getvalue(self, plus):
        return self.__x + plus
    
    @classmethod
    def getcvalue(cls, plus):
        return cls.cvalue + plus

这就是一种挺典型的类定义了,在进行面向对象编程的时候也很常见。除了类之外,我们还都知道,有一种数据类型叫做 dict ,即字典类型,该数据结构可以视为一个基于键值对,并支持增删查改的映射结构,一个典型的例子如下所示

h = {
  'a': 1,
  'b': 'this is a str value',
  'c': ['first', '2nd', 3],
  'd': {
    'content': 'nested dict is okay',
  }
}

你可能会感到奇怪,为什么我会突然笔锋一转,说起了字典类型。那我问你——要是我告诉你,类、对象和字典本质上是差不多的,你会不会感到难以置信呢?首先先说结论——在Python中,类、对象和字典类型,都是典型的映射结构。可以看下如下的这个例子,里面是一个最为简单的类,并通过 dir__dict__ 来展示了类与对象的部分内部结构

class T:
    def __init__(self, x, y):
        self.x = x
        self.y = y


if __name__ == '__main__':
    t = T(1, 2)
    print(dir(t))
    print(t.__dict__)
    print(dir(T))
    print(T.__dict__)

# ['__class__', '__delattr__', '__dict__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__le__', '__lt__', '__module__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__', '__weakref__', 'x', 'y']
# {'x': 1, 'y': 2}
# ['__class__', '__delattr__', '__dict__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__le__', '__lt__', '__module__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__', '__weakref__']
# {'__module__': '__main__', '__init__': <function T.__init__ at 0x7f43dc5f4e18>, '__dict__': <attribute '__dict__' of 'T' objects>, '__weakref__': <attribute '__weakref__' of 'T' objects>, '__doc__': None}

通过 dir 的输出结果可以看到,无论是类还是对象,内部都包含了大量的字段名,不仅如此,类和对象的字段名实际上高度相似,唯二的差异也分别是我们自己定义的字段 xy ,此处注意是字段(field)不是属性(property),虽然一般情况下这两个概念常常不作区分,但是此处需要消除歧义。因为实际上在Python中,类本质上也是一种对象,名为类对象的对象,如果说上述例子里对象 t 的类型为 T ,则类对象 T 的类型为 type ,基于这一点我们可以先建立起一个将类和对象统一起来的概念

而在上面的例子中,我们除了执行 dir 函数之外,还访问了对象的 __dict__ 值。而在对象 t 中,得到的值为 {'x': 1, 'y': 2}回忆一下上一章所述的类的构造方式,再看看类 T__init__ 方法中的实现

class T:
    def __init__(self, x, y):
        print('Initializing T', x, y)
        self.x = x
        self.y = y

把这两件事放在一起看,有没有联想到什么?没错,在这个例子里__dict__中读取到的值就是在构造过程 __init__ 中赋的值
不仅如此,我们再看看如果类之间存在继承关系,会发生什么,例如下面的例子

class T:
    def __init__(self, x, y):
        self.x = x
        self.y = y


class TP(T):
    def __init__(self, x, y):
        T.__init__(self, x, y)
        self.total = x + y


class TM(TP):
    def __init__(self, x, y):
        TP.__init__(self, x, y)
        self.mul = x * y


if __name__ == '__main__':
    t = TM(3, 5)
    print(t.__dict__)

# {'x': 3, 'y': 5, 'total': 8, 'mul': 15}

可以看到几级父类上 __init__ 赋的值都在 __dict__ 中。这一现象如果结合前一章对 __init__ 原理的解释,则成因是显而易见的——构造函数__init__的本质是一个工厂函数,从这个角度来看,则 TM.__init__ 也是一个工厂函数,而其内部直接或间接调用了 TP.__init__T.__init__ 这两个属于父类的工厂函数,因此可以将内部的装饰效果一并应用于当前对象中,形成类似类继承的效果

延伸思考3:如果对已经构造完毕的对象的某未定义的属性进行直接赋值(例如 t.undefined = 10 ),会发生什么现象?

延伸思考4:如何解释上面的现象?与构造函数中的属性赋值有何异同?

延伸思考5:类似的,如果将 t 赋值为 object() ,执行延伸思考3中的赋值操作,会发生什么现象?如何解释这一现象?(可以参考官方文档

欢迎评论区讨论!

如何手动制造一个对象

基于以上的分析,对类和对象的本质已经初见端倪——类和对象本质上也是一种映射结构,这一结构中存值的那一部分位于 __dict__ ,而存储业务逻辑的部分则是各个函数,它们在 dir(t) 中均可以找到名称,并且可以通过 getattr 进行访问(实际上在Python中,函数也同样是一个对象)。

因此,我们可以基于上述的原理,尝试构造一个简易的对象出来。例如下面的例子

class MyObject(object):
    pass


if __name__ == '__main__':
    t = MyObject()  # the same as __new__
    t.x = 2  # the same as __init__
    t.y = 5


    def plus(z):
        return t.x + t.y + z


    t.plus = plus  # the same as function def

    print(t.x, t.y)
    print(t.plus(233))

首先在第6行,我们模仿 __new__ 方法的思路,手动创建一个空对象(注意不能直接用 object ,而需要继承一层,具体原因详见[官方文档中的Note部分](https://{'x': 3, 'y': 5, 'total': 8, 'mul': 15}));接下来分别对对象的属性进行赋值,包括数值 xy ,以及一个会基于 t.xt.y 进行运算处理的函数 plus (一般我们更习惯于称之为方法);最后就是使用这一手动创建的对象,可以看到 t.xt.y均可正常使用,并且方法t.plus(z)也可以被正常调用。经过这一系列操作,一个手工创建的对象就产生了,而且从使用者的角度来看,也和正常实例化的对象并无差异

如何手动制造一个类

不仅对象,类也是可以手动制造出来的。话不多说,我们先看看来自官方文档的构造 type 类说明

class type(object)
class type(name, bases, dict, **kwds)
With one argument, return the type of an object. The return value is a type object and generally the same object as returned by object.class.
The isinstance() built-in function is recommended for testing the type of an object, because it takes subclasses into account.
With three arguments, return a new type object. This is essentially a dynamic form of the class statement. The name string is the class name and becomes the name attribute. The bases tuple contains the base classes and becomes the bases attribute; if empty, object, the ultimate base of all classes, is added. The dict dictionary contains attribute and method definitions for the class body; it may be copied or wrapped before becoming the dict attribute. The following two statements create identical type objects:

看起来挺长,不过后续附了一个最为简明扼要的例子

# first code
class X:
    a = 1

# second code, the same as the former one
X = type('X', (), dict(a=1))

所以其实依然不难理解,简单来说就是三个基本参数:

  • 名称( name )——字面意思,表示构造的类名
  • 基类( bases )——字面意思,表示所需要继承的基类
  • 字典( dict )——即需要赋予对象的属性

因此基于以上的原理,我们可以构造出来一个自己的类,就像这样

def __init__(self, x, y):
    self.x = x
    self.y = y


def plus(self, z):
    return self.x + self.y + z


XYTuple = type('XYTuple', (), dict(
    __init__=__init__,
    plus=plus,
))

if __name__ == '__main__':
    t = XYTuple(2, 5)
    print(t.x, t.y)
    print(t.plus(233))

# 2 5
# 240

# The definition of class is exactly the same as :
# class XYTuple:
#     def __init__(self, x, y):
#         self.x = x
#         self.y = y
# 
#     def plus(self, z):
#         return self.x + self.y + z

不难发现,从这样的视角来看,一个类的装配也大致分为三步:

  • “初始化阶段”——此阶段会创建一个指定名称的类对象
  • “继承阶段”——此阶段会尝试在类对象上建立与已有类的继承关系。
  • “装配阶段”——次阶段会将类所需的各个属性,装配至类对象上。

至此,经过了三个阶段后,一个类对象创建完毕,并且在使用上和正常定义的类并无差别。

延伸思考6collections 库中的 namedtuple 函数是如何构造一个类出来的?可以阅读一下源代码进行分析。

欢迎评论区讨论!

私有字段的本质

对于了解Python面向对象或学习过Java、C++等其他语言的读者,应该对私有字段这个东西并不陌生(如果还不够了解的话可以看看Python3 面向对象 - 类的私有属性)。在Python中,我们所熟知的私有字段大致是如下的形态

class T:
    def __init__(self):
        self.__private = 1   # private field, starts with __
        self._protected = 2  # protected field, starts with _
        self.public = 3      # public field, starts with alphabets

简单来说就是:

  • 私有字段,仅可以被类内部访问,以双下划线开头
  • 保护字段,可以被当前类及其子类访问,以单下划线开头
  • 公有字段,可以被自由访问,以字符开头

因此对上面的例子中,实际访问效果如下

t = T()
t.__private   # Attribute Error!
t._protected  # 2
t.public      # 3

保护字段和公有字段是可以被访问到的,但是一般情况下保护字段并不推荐在当前类或子类以外的地方进行访问(实际上当你这么做的时候,不少IDE都会报出明确的warning),而私有字段则无法访问,直接访问会导致报错。
看起来似乎一切很正常,但是让我们来看看上面例子中变量 t 内部都有什么

t.__dict__  # {'_T__private': 1, '_protected': 2, 'public': 3}

其中 public_protected 是意料之内的,但是除此之外还包含一个_T__private,并且其值正是在构造函数中所赋予的值。基于这一点,我们再来做个实验

t._T__private  # 1

发现私有字段居然也可以被访问。至此,我们可以得出一个结论——在Python中,并不存在严格意义上的私有字段,我们所知道的私有字段本质上更像一种语法糖效果,而保护字段则干脆是被摆在明面上的。
从这个角度来看不难发现,在Python中这些字段之所以还能起到私有字段或保护字段应有的效果,本质上靠的是开发者意义上的约束,而非语言系统本身的强制力。这一点和Java等静态语言存在本质上的差异,在Java中定义的私有字段一般无法通过正常途径进行访问,即便通过反射机制强制读取,也需要绕开一系列机制。

延伸思考7:类似Python的私有字段处理方式还在哪些语言中有所体验?类似Java的呢?

延伸思考8:以上的两种处理方式分别体现了什么样的思维方式?有何优劣?分别适合什么样的开发者与应用场景?

欢迎评论区讨论!

后续预告

本文重点针对类的特性,从原理角度进行了分析。在本系列的下一篇中,会重点针对类的方法和属性进行讲解,以及treevalue第三弹也将会在不久后推出,敬请期待。

此外,欢迎欢迎了解OpenDILab的开源项目:

以及我本人的几个开源项目(部分仍在开发或完善中):

posted @ 2021-11-15 17:15  HansBug  阅读(310)  评论(0编辑  收藏  举报