【专题策划】Python的对象与型式
《计算机教育》 2017年第9期 封面
0 引 言
对于Python程序设计语言对象与型式的概念、关系等基本概念,如果没有清晰的认知,那么所编写的Python程序(脚本)重者无法运行或者得到错误的结果,轻者可能隐含难以察觉和调试的逻辑错误。因此,了解Python程序设计语言中对象、型式与量的基本概念和关系,掌握文字与量、名空间与作用域、全局量与局部量的概念以及这些基础概念对量(对象)可能造成的影响至关重要。
1 对象与型式
1.1 对 象
对象(object)是一种数据抽象或数据结构抽象,用来表示程序中需要处理或已处理的信息。在Python程序设计语言中,对象具有3个基本特征:本征值(identity)、型式(type)和值(value)。
本征值是用于区分不同对象的信息,因而特征之一是应具有唯一性。在Python程序设计语言中,本征值的表示方式与Python程序设计语言的具体实现有关,一种典型的实现策略是使用对象在内存中的存储地址,如CPython的实现。本征值的另外一个特征是有常性(immutability),即一经创设就不可改变。
在Python程序设计语言中,可以使用本征值函数id()返回某个特定对象的本征值,如在Cpython实现中,id(1)与id(obj)分别返回对象1与对象obj的本征值。相应地,也可以使用型式函数type()获取某个对象的型式。
在Python程序设计语言中,依据该对象是否可被改变,而分为有常对象(immutable)和无常对象(mutable)。
一般而言,Python程序设计语言中的对象无常性由其型式确定。典型的示例,如数值(numeric)、字符串(string)和元组(tuple)为有常对象,词典(dictionary)和列表(list)为无常对象;此外,有常容器(container)对象可能包含无常元素对象,前者值不可变,后者则不然。对于这两点,初学者必须时刻保持警觉,教师在教学过程中也必须阐释清楚。
1.2 型 式
型式(type),简称型,也称类型。在纯面向对象语言出现之前,type用来表示相同性质的数据集合。该集合虽然具有明确的操作集,厘定了可在该集合上实施的操作,但是并未在语言层面上对其进行明晰的操作集定义,即数据及其操作是分离的。
型,其最主要的目的是构造该型式的对象。这意味着任何对象都必须有确切的型式,且一般不可改变。
在面向对象技术出现之后,程序设计语言一般使用专用关键字来表示特定的将数据与操作辩证统一的数据结构——类,如C++程序设计语言中的关键字class(含扩充定义的struct)。在类中,对象属性(attribute)和行为(behavior)被统一描述和管理:对象属性是类的数据成员;对象行为是该类或该类的某个对象上可执行的操作成员,也称为方法(method)。
语言学上,class的翻译为“类”,作为型(type)的一种,也可以称为“类型”。这使得其与早期术语type之间,容易出现一定混淆——早期非class类型的type也被翻译成“类型”。为避免引起误解,将type更正为“型式”更佳,有助于区分class与type ——两者在程序设计语言层面上并非同一概念。
Python程序设计语言作为一种纯粹的面向对象语言,凡物皆为对象,这导致学生在学习时会面临以下两方面的困难。
(1)class与type的本质完全相同,类即为型,而型亦为类。此时,讨论其他编程语言中这两者的差异,就没有任何意义。因此,很多学习过其他面向对象语言的学生在学习Python程序设计语言时,反而会面临概念理解上的困难。这一点,授课教师必须在教学过程中表述清楚,以减少学生的困惑。
(2)型也是可以在程序中操作的对象。可以认为,型就是构造对象的模板,然而在实际语言实现中,存在这样一种情况,即一个对象本身实际上可以作为该型另外一个对象的模板。这意味着,型本身也可以作为对象来存储和管理,并在程序运行过程中作为模板,用于构造该型的对象。因此,我们可以将程序编译从静态引向动态。
示例代码一:
>>> id(int)
1707211232
>>> type(int)
<class 'type'>
>>> id(float)
1707205632
>>> type(float)
<class 'type'>
>>> int is float
False
# 试试将一个浮点型赋值给一个整型
>>> int = float
>>> id(int)
1707205632
>>> type(int)
<class 'type'>
>>> id(float)
1707205632
>>> type(float)
<class 'type'>
>>> int is float
True
>>>
1.3 型之相
在Python程序设计语言中,类具有明确的型。类的定义语句负责创建(构造)一个类型,而类型用于创建(构造)该类型的对象(object)。
构造类的一个实际对象的过程称为具象化(instantiation),也称为实例化。实例化的结果为具象(instance object),也称实例对象。
实际上,在Python程序设计语言中,类定义结束时,系统将构造(创建)出该类的一个型象。如前所述,型亦为对象,因此,按照此型定义出的对象称为对象(class object),也称类象或类对象。
对于初学者而言,这种概念上的差别非常容易让人迷惑。类(型)本身就是构造对象的模板,那么类对象或型对象是什么?在教学过程中,教师必须特别强调Python程序设计语言的类(型)动态性,这一点与C语言和C++语言有很大的区别。因而,我们更倾向于将由类定义而创建(构造)出来的类对象称为型相,即使用“相”字区分实际对象的“象”字。
也就是说,对于任意一个类,其类定义创建(构造)了该类的一个型相;而通过该型相,程序员可以创建(构造)该类的具象——具体的象。对于前者,构造型相的过程,我们称为体化(型体化);对于后者,构造具象的过程被称为具象化(象化)。体化的书面意义是指“以自己的行动感化别人”,而“体”本身指“事物的本身或全部”“物质存在的状态或形状”或“事物的格局、规矩”,因此用“体化”描述这个过程似乎更恰当。
2 量与对象
2.1 文字与量
文字(literal)是内置类型的有常值,类似于数学或物理中的常数,如0、3.1 416、2.718 28j、“Python”等。一方面,在Python程序设计语言中,文字亦对象;另一方面,量(variable)是引用特定对象的标识符,类似数学中的代数。
在Python程序设计语言中,量的处理相当特殊。事实上,它已经与C语言、C++语言等早期语言中的概念有了明显差别。
首先,量可以随意引用数值、字符串或其他类型的对象;其次,量是标识符(identifier),是名称(name),但并不是该对象本身,仅仅是对“象”的引用(reference),即对该“对象”的“象”的引用;最后,对于Python程序设计语言中的量而言,赋值(assignment)即定义(definition),因而并不需要在使用量前作预先定义。这意味着赋值的目的并不是将赋值操作符右边表达式的结果,存入该量所对应的存储空间,而是将代表该量的名称(name)与赋值操作符右边表达式的结果所对应的值,束定(bind)或重新束定(rebind)在一起,以修改无常对象的属性或值。
这样就引申出一个问题,即量的同一性(identity)问题——我们如何判断两个量是否引用了同一个对象。虽然Python程序设计语言提供两个关键字(操作符)is和is not,用于测试量的同一性,但是实际问题是量的同一性依赖于Python程序设计语言的具体实现,如我们可以认定a = [] 与b = [] 总是引用不同的有常对象(空列表),a = b = [] 总是引用同一有常对象,却并不能保证a = 1与b = 1是否引用了值为1的同一有常对象。
2.2 名空间与作用域
在概念上,名空间(namespace)是从名称到对象的映射。在实现上,大多数名空间表现为符号表,以字典的形式来组织,而变量存储其中。在Python脚本中存在多个相互独立的名空间。
一般而言,名空间具有如下特性:
(1)标识符独立性:不同名空间的同名标识符没有任何关联;
(2)标识符唯一性:同一名空间中的标识符不得重名;
(3)名空间嵌套:一个名空间可以包含另外一个名空间。
在Python程序设计语言中,名空间分为内置名空间(built-in namespace)、全局名空间和局部名空间这3类。其中,内置名空间为内置名称集合,如内置函数名、内置异常名等;全局名空间为模块内部的全局名称集合;而局部名空间为函数调用时的本地名称集合、对象的属性集合、嵌套函数的本地名称集合或类成员函数的本地名称集合等。
需要说明的是,全局名空间也称模块全局名空间,在读入模块定义时创建,且正常情况下在Python解释器退出时删除。此处所说的“全局”仅指在该模块内部为全局的。每个模块都有独立的全局名空间,导入模块后方可访问其中的全局标识符;同时,模块中可能存在非全局标识符,即使导入模块也不可访问,这一点是初学者很容易犯糊涂的地方,教师在教学时须予以重视。
另一个相关的概念是作用域(scope)。定义上,作用域是指可访问名空间中标识符的文法区域,即在Python程序文本的某处,是否可以使用该名空间中的标识符。
虽然作用域与名空间在概念上有强关联,但是二者并不相同。一般认为,不在某名空间中,就不能访问该名空间中的标识符;同时,在某名空间中,也不一定能访问该名空间中的标识符。
在Python程序设计语言中,作用域分为局部作用域、外层函数闭包作用域、全局作用域和内置作用域这4类。其中,局部作用域位于最内层,为函数(类成员函数)、类或Lambda表达式形成的文法区域;外层函数闭包作用域为嵌套函数的外层函数形成的文法区域;全局作用域为模块形成的文法区域;内置作用域位于最外层,为包含内置名称的文法区域。
在进行标识符查找时,按照由内向外的顺序查找上述4种作用域。
2.3 全局量与局部量
一般将定义于类、函数、类成员函数或Lambda表达式之外场合的量称为全局量;将定义于函数、类成员函数或Lambda表达式中的量称为局部量。函数的形式参数与局部量类似,但是出于参数传递的原因,其与普通局部量有细微差异。
需要说明的是,全局量与局部量位于不同的名空间,因而可重名;同时,全局量和局部量重名时,局部量可能遮盖全局量的作用域,使其不可见。
在赋值即定义的原则下,Python程序设计语言中,全局量和局部量的差别经常让初学者迷惑,如以下3段代码。
示例代码二:
# n为全局量,位于全局名空间,其后代码(包括函数内部)均可访问
n = 42
# 形式参数x也为局部量,位于局部名空间,函数内部可访问
def double(x):
# 访问全局量n
print( "Before being doubled in double(): n = ", n )
# m为局部量,位于局部名空间,函数内部可访问
m = x * 2
print( "After being doubled in double(): m = ", m )
print( "After being doubled in double(): n = ", n )
return m
print( "Before calling double() in __main__: n = ", n )
# m为全局量,位于全局名空间,与函数内部m为独立的两个对象
m = double(n)
print( "After calling double() in __main__: m = ", m )
print( "After calling double() in __main__: n = ", n )
示例代码二的输出结果如下:
# 调用函数前,全局量n值为42
Before calling double() in __main__: n = 42
# 调用函数中,全局量n值为42(加倍前)
Before being doubled in double(): n = 42
# 调用函数中,局部量m值为84(加倍后)
After being doubled in double(): m = 84
# 调用函数中,全局量n值为42(加倍后)
After being doubled in double(): n = 42
# 调用函数后,全局量m接受加倍值84
After calling double() in __main__: m = 84
# 调用函数后,全局量n值维持42不变
After calling double() in __main__: n = 42
在示例代码二中,n为全局量;m既可能为全局量,又可能为局部量,这与其所在的文法区域有关,如函数内部的m为局部量,函数定义之后使用的m为全局量。
示例代码三如下:
# n为全局量,位于全局名空间,其后代码(包括函数内部)均可访问
n = 42
def double(x):
# 注释下一条语句,否则无法束定局部量n,引发UnboundLocalError异常
# print( "Before being doubled in double(): n = ", n )
# 定义同名局部量n(赋值即定义),新对象具有局部作用域,整个函数内部均有效
# 局部量n遮盖同名全局量n的部分作用域,使其不可见
# 局部量n定义前虽不能访问,但仍不允许上条注释语句访问全局量n
# 换言之,即使前述被注释的那条语句出现在局部量n定义之前,n也被解释为局部量
n = x * 2
print( "After being doubled in double(): n = ", n )
return n
print( "Before calling double() in __main__: n = ", n )
m = double(n)
print( "After calling double() in __main__: m = ", m )
print( "After calling double() in __main__: n = ", n )
示例代码三的输出结果如下:
# 调用函数前,全局量n值为42
Before calling double() in __main__: n = 42
# 调用函数中,局部量n值为 42
After being doubled in double(): n = 84
# 调用函数中,全局量m接受加倍值84
After calling double() in __main__: m = 84
# 调用函数后,全局量n维持原值42不变
# 即全局量n与局部量n虽同名,但不是同一对象
After calling double() in __main__: n = 42
在示例代码三中,视定义n时的文法区域,n可能为全局量,也可能为局部量;m则为全局量。相关代码解释已列入程序注释,此处不再赘述。
再看示例代码四:
# n为全局量,位于全局名空间,其后代码(包括函数内部)均可访问
n = 42
# 直接使用全局量n,无需传递参数
def double():
# 声明全局量n,函数内部对其赋值不会构造新的局部对象
global n
print( "Before being doubled in double(): n = ", n )
# 直接写入全局量,而不是构造新的局部对象
n = n * 2
print( "After being doubled in double(): n = ", n )
return n
print( "Before calling double() in __main__: n = ", n )
m = double()
print( "After calling double() in __main__: m = ", m )
print( "After calling double() in __main__: n = ", n )
示例代码四的输出结果如下:
# 调用函数前,全局量n值为42
Before calling double() in __main__: n = 42
# 调用函数中,全局量n值为 42(加倍前)
Before being doubled in double(): n = 42
# 调用函数中,全局量n值更新为84(加倍后)
After being doubled in double(): n = 84
# 调用函数后,全局量m接受加倍值84
After calling double() in __main__: m = 84
# 调用函数后,全局量n维持更新后的值84不变
After calling double() in __main__: n = 84
由于赋值定义的特性,要在Python函数内部修改全局量的值,就必须使用global将其声明为全局量,原因是在函数内部可以自由引用全局量,但不能对其赋值——赋值隐含着构造新的同名局部对象。
类似的关键字还有nonlocal,用于将其后标识符解释为非局部非全局的。查找标识符时,Python解释器将从最内层嵌套名空间向外查找,一直到全局名空间(不含);若未找到该标识符,系统将引发SyntaxError异常。
无论是global还是nonlocal,标识符在其定义所在的代码块各处均有效,包括该声明之前。
3 结 语
本文探讨了Python程序设计语言中的对象、型式与量的基本概念和关系以及对象与型式的基本概念,指出了型相与具象的本质及两者之间的差异,并解释了文字与量、名空间与作用域、全局量与局部量的概念,指出了这些基础概念对量(对象)可能造成的影响。在实际编程时,对这些概念的理解偏差极大地影响程序的健壮性和正确性,因而需要在教学过程中予以详尽的说明。
基金项目:国家自然科学基金项目“碎片化知识聚合方法研究”(61532015)。
作者简介:乔林,男,副教授,研究方向为并行编译优化、知识挖掘与机器学习等,qiaolin@tsinghua.edu.cn。
更多精彩:
“互联网+ 教育”:TPACK 理念下职业教育O2O教学模式改革探索
【封面文章】面向计算生态的Python 语言入门课程教学方案
【校长专访】勇做职业院校信息化人才培养的排头兵——深圳信息职业技术学院孙湧校长专访