CS61A fa2021 Composing Programs 2.7 Object Abstraction 对象抽象

原文

2.7   对象抽象(Object Abstraction

面向对象为我们提供了多种数据抽象形式。各种抽象形式之间严密的逻辑层级使得我们在建立与使用时,不用担心他们之间会发生冲突。

对象抽象中的一个核心概念是泛型函数,它是一个可以接受多种不同类型值的函数。实现泛型函数需要三种方法:共享接口(shared interfaces)、类型分派(type dispatching)和类型强制(type coercion),我们会一一认识他们。同时在构建这些概念的过程中,我们还将发现 Python对象系统的某些特性。

2.7.1  字符串转换(String Conversion

在交互式语言(如pyhon)中,计算机需要把数据转换成字符串,才能为用户展示某个对象或者某个数据的值。举个例子,用户要求打印整形数1,计算机则会打印字符串‘1’。

我们可以认为人与人之间进行交流的实际基本媒介是字符串。在屏幕上、纸上显示的与我们大声朗读的,也可以看做字符串。就算翻译成盲文,摩斯电码,他依旧是字符串,同理,python的代码也是字符串。字符串是编程的基础。

python提供了两种表示对象(python中万物皆对象)的字符串形式,调用str函数就能返回一个给人读的字符串;另一种是给python自己读的,调用repr函数会返回一个表达式,这个表达式的计算结果就是该对象本身。

调用repr和在交互式窗口中调用print是一样的。

>>> 12e12
12000000000000.0
>>> print(repr(12e12))
12000000000000.0

整型,浮点型或者字符串等等repr会用字面量表示的。遇到其他情况,python此时会生成一个描述性的文本,并用尖括号括起来。

>>> repr(min)
'<built-in function min>'

str函数和repr函数像一对双胞胎兄弟。不过他们之间肯定是有区别的,正如我们先前提到的。str函数所转换出来的字符串是供人阅读的。从下面的例子就可以很明显的看出来。

>>> from datetime import date
>>> tues = date(2011, 9, 12)
>>> repr(tues)
'datetime.date(2011, 9, 12)'
>>> str(tues)
'2011-09-12'

看到这我们或许会感叹:啊!repr函数真是有经天纬地之才!天下数据如此多种,他竟然全能一一辨认!可问题来了,它到底是怎么做到的呢?repr函数一旦被定义之后就不会改变了,每一步都是固定的。但数据可是想创建就创建的。repr函数是如何做到一招鲜吃遍天的呢?我们大概能猜到,python的发明者在定义repr函数时就把它设置成一个通用的、多态的函数(泛型函数),即能接受各种类型数据为参数的函数。

python的OOP系统完美的为我们解决了这个问题。“这是他自己告诉我的!”这就是repr函数以不变应万变的秘籍。repr自然不知道这么多种数据都是些什么类型,他是遇到哪个数据就问哪个数据“嘿!你是什么类型的?”。在我们给repr函数传入数据时,repr会调用该对象(python万物皆对象,都属于object类的子类)的一个内置方法__repr__。

>>> tues.__repr__()
'datetime.date(2011, 9, 12)'

相当于每创建一个类,都会从其父类上继承一个“内奸”属性__repr__。repr函数只要通过点表达式,通知某个类的“内奸”属性:“嘿!告诉我你主子是什么类型的数据!”,便可不出户而知天下。

不过阴险狡诈的可不止repr函数一人,str函数也有自己的情报系统和内奸网,就是内置方法__str__。

>>> tues.__str__()
'2011-09-12'

repr函数和str函数的存在也从侧面告诉我们:有那么一些函数,有着处理多种类型数据的能力。

2.7.2   魔术方法(Special Methods

在进行某些操作时,python会在内部调用一些特殊的方法,我们称之为魔法方法。比如创建实例时,会自动调用__init__方法;执行print语句时会调用__str__方法等。

python还有很多魔法方法,下面列举一些常见的魔法方法,并一一给大家介绍。

布尔值(True and false values)

布尔值是个很神奇的东西。在整形数中,除了0表示为假,其他不论正数负数皆表示为真。我们可以大胆点说,“万物皆布尔"。python中任何对象都可以表示为一个布尔值,并且用户创建的所有实例,默认状态下都表示为真。当然,你可以有自己的想法!只需要重写他继承下来魔法方法__bool__就能达到你的目的。

举个例子,假设我们希望余额为0的银行账户表示为假。那么我们可以在Account类中重写一个 __bool__ 方法来创建这种行为。

>>> Account.__bool__ = lambda self: self.balance != 0

接着我们调用bool函数来看看改动是否成功。

>>> bool(Account('Jack'))
False
>>> if not Account('Jack'):
        print('Jack has nothing')
Jack has nothing

序列操作(Sequence operations)

len函数可以用于计算一个序列的长度。

>>> len('Go Bears!')
9

其实len函数背后也有个魔法方法__len__,用于计算某个对象的长度。python的所有内置序列,包括字符串啦,列表啦,元组啦等等都能直接调用这种方法。

>>> 'Go Bears!'.__len__()
9

万物皆布尔,序列也难逃,序列也可以通过bool函数计算为布尔值。在python中,默认情况下,空序列也就是长度为0的序列计算为False,其他序列皆为True。

>>> bool('')
False
>>> bool([])
False
>>> bool('Go Bears!')
True

序列也有属于自己魔法方法,其中__getitem__方法用于获取序列中的特定元素。

>>> 'Go Bears!'[3]
'B'
>>> 'Go Bears!'.__getitem__(3)
'B'

可调用对象(Callable objects

在python中,函数作为第一类对象,可以被调用、可以向数值一样被传递,同时也可以拥有自己的属性。同时,我们也可以创建属于自己的可调用对象,只要借魔法方法__call__之手。并且你会发现让他们和高阶函数十分相似。

先给大家看一个高阶函数。

>>> def make_adder(n):
        def adder(k):
            return n + k
        return adder
>>> add_three = make_adder(3)
>>> add_three(4)
7

接下来我们创建一个可调用对象Adder,并重写他的 __call__魔法方法,来达到与该高阶函数相同的功能。

>>> class Adder(object):
        def __init__(self, n):
            self.n = n
        def __call__(self, k):
            return self.n + k
>>> add_three_obj = Adder(3)
>>> add_three_obj(4)
7

我们可以看到,Adder类和make_adder函数、add_three_obj对象和add_three函数都有着相同的功能。我们进一步模糊了数据和函数之间的界限。

算术(Arithmetic

魔法方法的功效远比我们想象得要强大。我们甚至可以通过更改__add__与__radd__来改变+号的功能!不过,具体的就不多加叙述了。

2.7.3  多样表示(Multiple Representations)

抽象屏障把数据的抽象表示和抽象数据的使用分割开来,所以我们不需要随时都暴露原始数据。在大型程序中,可能还存在多种数据的抽象表示方法。

举个例子,复数就能用两种形式进行抽象表示:直角坐标形式和极坐标形式。这两种表现形式有可能同时出现,但尽管如此也不影响我们在不破坏抽象障碍的情况下对复数进行操作。待会就让我们用代码说话,创一个简单的复数计算系统。

插一句嘴,这时或许有人会问?为什么一定要有那么多种表示数据的方法?统一方法不是更方便吗?在大型软件的开发中,首先由于数据量的庞大,加上开发时间跨度长,在这样的条件下,每个人都不可能就数据抽象的表现形式上都达成一致。所以我们还会要求抽象屏障把隔离数据的使用和表示的同时,也把不同的设计模式隔离开来,以至于允许他们协同工作于一个程序中。

我们的工作会从最高层级的抽象开始,然后一步一步往具体的方向前进。第一步,我们先创建一个Number类来表示一般的数字,里面含有用来进行加和乘操作的方法add、mul等。

>>> class Number:
        def __add__(self, other):
            return self.add(other)
        def __mul__(self, other):
            return self.mul(other)

我们没有给Number类添加__init__方法,是因为我们创建Number类是为了让他在最高级层级的抽象做父类。接下来,我们为复数重写__add__与__mul__方法。

一个复数可以看作是一个二维坐标,坐标系的两条轴分别是实轴与虚轴。把实轴看成横轴,其坐标用real表示,把虚轴看成纵轴,其坐标用image表示,那么一个复数c写成real + imag * i(i * i = -1)。

我们从极坐标的角度去考虑计算复数的乘法,即一个看作长度,一个看作角度。两个复数的乘积是将一个复数按另一个复数的长度因子拉伸,与角度因子旋转后得到的向量。

我们开始创建Complex子类,并重写他的算术方法。

>>> class Complex(Number):
        def add(self, other):
            return ComplexRI(self.real + other.real, self.imag + other.imag)
        def mul(self, other):
            magnitude = self.magnitude * other.magnitude
            return ComplexMA(magnitude, self.angle + other.angle)

在我们重写的__add__与__mul__方法中,我们可以看到两个不同的复数类:

1、ComplexRI,指通过直角坐标系,实虚二轴表示的复数。

2、ComplexMA,指通过极坐标,长度与角度表示的复数。

接口(Interfaces

之前简单提到过‘接口’这个名词,他属于“抽象的抽象”,是一个类属性的集合。这些类属性子多个子类之间“抽象上共享,具体上独立"。比如,不论是用直角坐标还是极坐标方法来表示虚数,都应该有用于计算虚数加法的方法与用于计算虚数乘法的方法。但在具方法的体实现上,他们又各自不同。基于这个例子,我们需要创建的接口应该包含的属性有:实部real,虚部imag,长度magnitude与角度angle。

在上面的Complex类的中,我们在__add__和__mul__方法的内部写入了如何运用这些属性进行计算复数的加法与乘法,这样就完成了接口。

属性(Properties

有时候我们需要在所属不同数据抽象形势下的类的属性中进行转换,找到他们之间的数学关系就十分重要了。因为直接保留转换值是不理想的,我们更应该保留转换的方法,不过可以以值的形式展现出来。在python为我们提供了十分实用的语法糖——@property。这是一个装饰器,它允许我们无需按照语法规则来调用函数。在@property的帮助下,我们可以在直角坐标的复数类ComplexRI中储存极坐标复数类的属性。

>>> from math import atan2
>>> class ComplexRI(Complex):
        def __init__(self, real, imag):
            self.real = real
            self.imag = imag
        @property
        def magnitude(self):
            return (self.real ** 2 + self.imag ** 2) ** 0.5
        @property
        def angle(self):
            return atan2(self.imag, self.real)
        def __repr__(self):
            return 'ComplexRI({0:g}, {1:g})'.format(self.real, self.imag)

在调用方法magnitude或者angle时,我们可以不需严格按照语法要求。

>>> ri = ComplexRI(5, 12)
>>> ri.real
5
>>> ri.magnitude
13.0
>>> ri.real = 9
>>> ri.real
9
>>> ri.magnitude
15.0

至此,我们的复数计算系统已经完成了。

>>> from math import pi
>>> ComplexRI(1, 2) + ComplexMA(2, pi/2)
ComplexRI(1, 4)
>>> ComplexRI(0, 1) * ComplexRI(0, 1)
ComplexMA(1, 1 * pi)

2.7.4  泛型函数(Generic Functions

之前我们就已经介绍过了何为泛型函数,在复数系统中,__add__就是一个泛型函数。因为他可以同时接受ComplexRI与ComplexMA两种类型的数据,这得益于这两种复数共享一个接口。这只是实现泛型函数的其中一种方法。在本章节我们会介绍另外两种:类型传递与强制类型转换。

我们可以在创建一个有理数Rational类来表示分数,并且沿用在之前的章节中我们为该类创建的add_rational与mul_rational。

>>> from fractions import gcd
>>> class Rational(Number):
        def __init__(self, numer, denom):
            g = gcd(numer, denom)
            self.numer = numer // g
            self.denom = denom // g
        def __repr__(self):
            return 'Rational({0}, {1})'.format(self.numer, self.denom)
        def add(self, other):
            nx, dx = self.numer, self.denom
            ny, dy = other.numer, other.denom
            return Rational(nx * dy + ny * dx, dx * dy)
        def mul(self, other):
            numer = self.numer * other.numer
            denom = self.denom * other.denom
            return Rational(numer, denom)

因为我们在基类Number中实现了接口,所以在定义完Rational类后,我们就可以直接对有理数进行加法与乘法计算了。

>>> Rational(2, 5) + Rational(1, 10)
Rational(1, 2)
>>> Rational(1, 4) * Rational(2, 3)
Rational(1, 6)

到现在为止,我们还不能进行有理数和复数之间的加乘法运算(尽管在数学上是被允许的)。不过不用担心,我会为你们介绍该如何去完成这样的跨类型操作,在不打破抽象屏障,保持各个抽象完整性的情况下。

类型分派(Type dispatching

实现跨类型操作的其中一种方法便是类型分派。根据你传递进来的数据类型进行相应的操作,调用相应的函数或者方法。

先给大家介绍一下isinstace函数,该函数获取两个参数,一个是对象,一个是类。它用于检测输入的对象是否属于这个类或者是否属于该类的子类。

>>> c = ComplexRI(1, 1)
>>> isinstance(c, ComplexRI)
True
>>> isinstance(c, Complex)
True
>>> isinstance(c, ComplexMA)
False

一个简单的类型分派的例子见下面的is_real函数,用于检测一个数是否为实数。

>>> def is_real(c):
        """Return whether c is a real number with no imaginary part."""
        if isinstance(c, ComplexRI):
            return c.imag == 0
        elif isinstance(c, ComplexMA):
            return c.angle % pi == 0
>>> is_real(ComplexRI(1, 1))
False
>>> is_real(ComplexMA(2, pi))
True

并非只要类型分派都会使用isinstance函数。我们可以设置一个类型标签,用于标记数据的类型。只有在两个数据类型不同时,我们才考虑跨类型操作。

>>> Rational.type_tag = 'rat'
>>> Complex.type_tag = 'com'
>>> Rational(2, 5).type_tag == Rational(1, 2).type_tag
True
>>> ComplexRI(1, 1).type_tag == ComplexMA(2, pi/2).type_tag
True
>>> Rational(2, 5).type_tag == ComplexRI(1, 1).type_tag
False

接下来写跨类型操作的函数,考虑到一个分数可以近似转换为一个浮点数,计算浮点数与复数的加法就容易多了。

>>> def add_complex_and_rational(c, r):
        return ComplexRI(c.real + r.numer/r.denom, c.imag)

乘法也需要进行同样的转换。当然啦,在此之上还需要一点简单的分析。在极坐标表示法中,任何实数都能以一个大于等于零的长度和0°(正数)或者180°(负数)表示。

>>> def mul_complex_and_rational(c, r):
        r_magnitude, r_angle = r.numer/r.denom, 0
        if r_magnitude < 0:
            r_magnitude, r_angle = -r_magnitude, pi
        return ComplexMA(c.magnitude * r_magnitude, c.angle + r_angle)

乘法与加法是具有交换律的,我们可以交换一下参数的顺序,结果依旧不变。

>>> def add_rational_and_complex(r, c):
        return add_complex_and_rational(c, r)
>>> def mul_rational_and_complex(r, c):
        return mul_complex_and_rational(c, r)

接下来,我们再更改一下超级类Number的__add__方法与__mul__方法来进一步实现我们的类型分派。

我们设置一个类型标签type_tag作为类属性,用于标记传入的数据类型。当然也可以用isinstance函数,但这里用一个类型标签会简单得多。不使用isinstance函数同时也说明了类型分派不一定只能用于OOP系统之中,在任何跨类型操作中都能使用它。

我们在写__add__方法时需要考虑两种情况。第一种当传入的两个参数是同类型的数据时,此时可以直接把这两个参数直接传入add方法即可。第二种为当传入的两个参数数据类型不同,这时我们会在一个名为adder的字典中寻找是否拥有相应的跨类型操作函数,若存在,则调用它。__mul__方法也类似。

>>> class Number:
        def __add__(self, other):
            if self.type_tag == other.type_tag:
                return self.add(other)
            elif (self.type_tag, other.type_tag) in self.adders:
                return self.cross_apply(other, self.adders)
        def __mul__(self, other):
            if self.type_tag == other.type_tag:
                return self.mul(other)
            elif (self.type_tag, other.type_tag) in self.multipliers:
                return self.cross_apply(other, self.multipliers)
        def cross_apply(self, other, cross_fns):
            cross_fn = cross_fns[(self.type_tag, other.type_tag)]
            return cross_fn(self, other)
        adders = {("com", "rat"): add_complex_and_rational,
                  ("rat", "com"): add_rational_and_complex}
        multipliers = {("com", "rat"): mul_complex_and_rational,
                       ("rat", "com"): mul_rational_and_complex}

我们可以看到所有的跨类型操作都被存放在字典adder与字典multipliers中。

这种使用字典的类型分派是可以更新的。在我们新建了一个类型的数据,同时又规定了新的跨类型操作方法后,就可以把它新增到字典里。

如今,在我们的努力之下,已经可以做到跨数据类型的加法和乘法了。

>>> ComplexRI(1.5, 0) + Rational(3, 2)
ComplexRI(3, 0)
>>> Rational(-1, 2) * ComplexMA(4, pi/2)
ComplexMA(2, 1.5 * pi)

强制多态(Coercion

有时各种类型的数据之间并非那么泾渭分明,他们之间可以互相转化。利用好这个特点能很大程度上减轻我们实现跨类型函数的工作。这种做法我们称之为强制多态。举了例子,若我们要把一个有理数与一个复数相加,此时有理数可以看作一个虚部为0的复数。这样我们就能用Complex.add把他们加在一起了。

我们可以写一个强制多态的函数来实现这个类型转换工作。

>>> def rational_to_complex(r):
        return ComplexRI(r.numer/r.denom, 0)

把他加入到Number类中,替换原来的类型分派,我们就能以另一种方式实现跨类型操作。在字典coercions中存放着所有可用的强制多态函数。

不过,让所有数据都能互相转换似乎是不太可能的事。比如一个复数就无法转换为有理数。在字典coercions中再怎么找也肯定找不到这样的强制多态函数。

调用coerce方法时,他会检查传入的两个数据的数据标签,并在 coercions字典中检索是否有相应的数据转换函数coerce_to,有则转换其中一个数据的类型,并返回两个相同类型的数据。我们按要求重新写一遍Number类。

>>> class Number:
        def __add__(self, other):
            x, y = self.coerce(other)
            return x.add(y)
        def __mul__(self, other):
            x, y = self.coerce(other)
            return x.mul(y)
        def coerce(self, other):
            if self.type_tag == other.type_tag:
                return self, other
            elif (self.type_tag, other.type_tag) in self.coercions:
                return (self.coerce_to(other.type_tag), other)
            elif (other.type_tag, self.type_tag) in self.coercions:
                return (self, other.coerce_to(self.type_tag))
        def coerce_to(self, other_tag):
            coercion_fn = self.coercions[(self.type_tag, other_tag)]
            return coercion_fn(self)
        coercions = {('rat', 'com'): rational_to_complex}

可以发现,我们把数据转换与转换后的数据操作分离开了,而不是写在一起。因为跨数据类型操作的重点在于对类型的处理而不是对操作的处理。这样能大大减轻后期的维护与增删工作。

强制多态函数远不止这么简单,有的强制多态函数并非简单的一对一转换,而是将多种不同类型的数据而不是多个不同转换为一种相同类型的数据。例如把菱形和矩形都转换为四边形。强制多态的另一种拓展是迭代多态,他是把一个数据进行多次中间转换来完成最终转换。比如一个整数可以先转换为一个有理数,再转换为实数。这样链式转换能是对多个强制多态函数的整合,在这种整合下能大大减少需要定义的函数总量。

尽管如此,强制多态也有自己的缺点,在进行类型转换时可能会发生数据的丢失。例如在之前提到的有理数与复数转换的例子中,转换前的有理数是一个精确的值,但是转换为复数时需要做除法,而变成了了一个近似值。

在某些编程语言中,具有内置的自动强制多态系统。实际上,在早期版本的python中,每个对象都具有__coerce__魔法方法。不过从后期表现看,他并不实用,于是被移除了。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值