深度学习程序设计实战
上QQ阅读APP看书,第一时间看更新

1.3 面向对象的程序设计

面向对象方法的核心有两个,一个是封装,另一个是继承。简单地说,封装就是把程序和数据绑定在一起,所以调用一个函数(或者说方法,以下同)的时候,一般要指明调用的是哪个对象的函数。继承就是让子类可以做两件事:第一,定义新的函数;第二,重定义父类中的函数。下面我们以实例来说明面向对象方法的妙用。

1.3.1 方法重定义和分数

一般计算机语言中没有分数类型,Python也不例外。不过,我们可以创建一个分数类Fraction,然后实现分数的加减乘除。这个类的框架如下:

在主程序中,我们首先调用Fraction(3,4),以便创建一个分数对象3/4。这时,Python约定会调用Fraction类中定义的构造函数[1]__init__(),并传入相应的参数3和4。在Python中构造函数是__init__(),而在Java和C++中构造函数约定就是类的名字。例如,如果用Java或者C++也编写一个类Fraction,那么它的构造函数就是Fraction(),而不是所谓的__init__()。另外,Python中的任何函数(包括普通函数、内部函数、类的成员函数、类的静态函数和构造函数)不能重复定义。如果重复定义了,Python运行时也不报错,而是用最后一个同名函数替换。也就是说,Python不支持面向对象方法中所谓的重载[2](Overload),但是Java和C++等都支持。顺便提一句,它们仨都支持重定义[3](Overriden)。关于Python的重定义我们马上就会讲到。

定义好分数f1之后,我们紧接着就打印它。print()是Python的内部函数。这样的内部函数还有很多,例如len()是求一个字符串或者列表的长度等。print()在打印一个对象时,约定调用的是这个对象的内部成员函数__repr__(),就像Java在打印一个对象时会调用它的toString()方法一样。所以我们在类Fraction的内部成员函数__repr__()中使用了字符串,并用%操作把分数对象的分子和分母转成形如“(分子/分母)”的字符串。其中的关键字self是对当前对象的引用,意义与Java和C++中的this相同。不同的是,Python的成员函数的第一个形式参数必须是self。

大家可以运行一下代码1-17,会得到分数“(3/4)”。

下面,我们实现分数的加法运算。值得一提的是,和C++一样,Python也可以对运算符(例如+)进行重定义。我们只需在Fraction类中定义函数__add__()即可。代码如下:

运行以后得到结果(17/12)。根据同样的方法,我们可以增加减、乘、除等方法。注意,除法对应的函数名是__truediv__。代码如下:

有意思的是,我们甚至可以直接进行组合运算,或者使用括号改变运算次序。例如,如果打印“f1∗(f1+f2)”,结果就是(51/48)。

除了加减乘除之外,还可以重定义其他运算符,见表1-2。

表1-2 Python运算符/操作与内部成员函数对照表

(续)

(续)

运算符之间的优先级遵循Python的约定。

表1-2展示了哪些运算符和内部函数可以被重定义。事实上,对分数来说,大部分运算是不必重定义的。例如,位运算符(&和|)对分数来说就没有什么实际意义。

表1-2中没有逻辑运算符(not、and、or),这说明逻辑运算是不可以重定义的。另外,除了求相反数(-)和按位取反(~)两个一元运算之外,所有算术运算符和位运算符都是二元的,并且都分别对应两个函数。例如加法(+)对应__add__()和__radd__()。其中__add__()表示当前对象对应的是两个运算元中左边那个,如f1+f2就会调用f1.__add__()函数,而不会调用f2.__add__()函数。而__radd__()表示当前对象对应的是两个运算元中右边[4]那个,如3+f2就会调用f2.__radd__()函数,并把3作为参数传入。

考虑到整数与分数的加减乘除的确存在,我们把加减乘除函数改动一下,以适应参数是一个整数的情况,再把右加、右减、右乘和右除重定义,得到如下代码:

输出结果略。

1.3.2 二十四点问题

下面我们结合上节提到的表达式、加减乘除、运算的概念,解决著名的二十四点问题。

二十四点问题的规则是,给出4张扑克牌,利用它们的点数,结合任意的加减乘除运算,以使最终的结果等于24。例如,10、2、3、7可以凑成表达式10×2+(7-3),其结果为24。

这个问题的主程序比较简单,就是利用numpy生成4个1~13之间的随机整数,然后调用子程序以获取用这4个数生成24的所有运算表达式,最后再打印出来。代码片段如下:

接下来我们就要考虑函数make24()的实现了。我们可以用1、2节学习过的递归方法进行思考。显然这个问题的输入参数是列表numbers,输出是这些数所能凑成的表达式及其相应的值。例如[3,5,2]就能凑成“3+5+2”“3+5-2”“3 ×(5+2)”等各种各样的表达式,对应的值分别是10、6、21等。

明确了输入和输出之后,我们来确定递归边界。numbers处于什么状态时问题最简单,我们可以直接给出输出?显然当numbers中仅含有一个数时,输出就是这个数本身。

递归假设比较简单,我们直接看递归推导。我们可以把numbers中的数据任意分成左右两个列表,每个列表中至少含有一个数。例如[3,5,2]就可以分成[3]和[5,2]、[5]和[2,3]、[2]和[3,5]等。根据递归假设,每个列表都可以通过make24()函数计算出一组值及其对应的表达式。我们只需从左右两组值中任意各取一个值,按照加减乘除4种运算凑成表达式即可。代码如下:

注意,在做减法和除法时,要把运算符左右两边交换的情况也考虑在内。

接着,我们要考虑的是make24()中调用的子程序split(numbers),它用来把numbers列表中的数据分成任意两个部分,每一部分至少含有一个数,并分别称为左部和右部。我们用变量lefts表示左部列表中数的个数,lefts从1到len(numbers)//2循环。循环体内则调用子程序get_indices_list(indices,lefts),以获取下标列表indices中任意lefts个下标构成的组合的集合,其中indices={0,1,2,…,len(numbers)-1},是numbers的所有可能的下标的集合。例如get_indices_list([0,1,2],2)的返回结果是{{0,1},{0,2},{1,2}}。最后用indices减去每个下标组合就可以得到相应的右部下标组合的集合,例如在上例中,右部下标组合分别是{2}、{1}、{0}。搞清楚了split()的来龙去脉,再来看它的代码就不难了:

其中包itertools中的函数combinations(numbers,num)用来获取由序列numbers中任意num个元素构成的组合的集合。这正是get_indices_list()要达到的目的。如果你对Python并不是太熟悉,不知道有这个包以及这个函数,或者你使用的是C++或Java之类没有这类库函数的语言,那么你也可以用递归方法直接实现这个函数。我们在代码1-15解决组合问题的递归程序中已经有了解决方案,这里不再赘述。

上述所有子程序实现之后,运行程序就可以随机产生4个1~13之间的整数,然后回答用它们能否凑出24。如果能,输出所有可能的表达式。下面是一个示例:

[8 6 12 11]

1.(6 ∗(12 /(11-8)))

2.(6 /((11-8)/ 12))

3.(6 ∗(12 /(11-8)))

4.(6 /((11-8)/ 12))

5.(12 ∗(6 /(11-8)))

6.(12 /((11-8)/ 6))

7.(12 ∗(6 /(11-8)))

8.(12 /((11-8)/ 6))

9.((6 ∗12)/(11-8))

10.((12 ∗6)/(11-8))

11.((6 ∗12)/(11-8))

12.((12 ∗6)/(11-8))

13.((6 ∗12)/(11-8))

14.((6 ∗12)/(11-8))

15.((12 ∗6)/(11-8))

16.((12 ∗6)/(11-8))

当然,这其中存在着不少重复的表达式。如何消除其中重复的表达式,留待读者思考和练习。