python 函数式编程学习笔记
函数基础
一个函数就是将一些语句集合在一起的部件,它们能够不止一次地在程序中运行。函数的主要作用:
- 最大化的代码重用和最小化代码冗余
- 流程的分解
一般地,函数讲的流程是:告诉你怎样去做某事,而不是让你使用它去做某事。
Python 中的 def
语句实际上是一个可执行的语句:当它运行时,它会创建一个新的函数对象并将其赋值给一个变量名。因为它是一个语句,一个 def
可以出现任一语句可以出现的地方——甚至是嵌套在其他的语句中。例如,下面的使用也是合法的:
test = ...
if test:
def func():
...
else:
def func():
...
...
func()
def
在运行时才进行评估,而在 def
之中的代码在函数调用后才会评估。
Python 中的多态
下面以一个 times()
函数为例来说明:
def times(x, y):
return x * y
times(2, 5)
结果:
10
times(3.14, 4)
结果:
12.56
times('Ni', 4)
结果:
'NiNiNiNi'
从上面我们可以看出:times
函数中表达式 x*y
的意义完全取决于 x
与 y
的对象类型。这种依赖类型的行为称为多态。任何支持函数所预期的接口 (函数所执行的一组方法和表达式运算符) 都能够被函数所支持。
作用域
当你在一个程序中使用变量名时,Python 创建、改变或查找变量名都是在所谓的命名空间 (作用域,一个保存变量名的地方) 中进行的。也就是说,在代码中变量名被赋值的位置决定了这个变量名能被访问到的范围。包括作用域的定义在内,所有变量名都是在 Python 赋值时产生的。
- 内嵌的模块是全局作用域
- 全局作用域的作用范围仅限于单个文件
- 每次对函数的调用都创建了一个新的本地作用域
- 赋值的变量名除非声明为
global
或者nonlocal
,否则均为本地变量 - 所有其他的变量名都可以归纳为本地、全局或者内置的
- 对函数的每次调用都会创建一个新的本地作用域
对对象的就地更改(in-place changes)不将变量归类为局部变量;只有实际的名称分配。例如, 如果将名称 L
分配给模块顶层的列表, 函数中的语句 L = X
会将 L
作为本地分类, 但 L.append(X)
不会。在后一种情况下, 我们正在更改 L
引用的 list
对象, 而不是 L
本身在全局范围中像往常一样被发现, 并且 Python 在不需要全局 (或非本地) 声明的情况下愉快地修改它。与往常一样, 它有助于保持名称和对象之间的区分清楚: 更改对象不是对名称的赋值。
Name Resolution: The LEGB Rule
变量名解析:LEGB 原则
- Name assignments create or change local names by default.
- Name references search at most four scopes: local, then enclosing functions (if any), then global, then built-in.
- Names declared in global and nonlocal statements map assigned names to enclosing module and function scopes, respectively.
工厂函数
闭合 (closure) 或者工厂函数:一个能够记住嵌套作用域的变量值的函数,尽管那个作用域或许已经不存在了。
例如,工厂函数有时用于需要及时生成事件处理、实时对不同情况进行反馈的程序中:
def maker(N):
def action(X):
return X ** N
return action
f = maker(2) # pass 2 to N
f
<function __main__.maker.<locals>.action(X)>
f(3)
9
f(4)
16
函数的不定长参数
当函数的参数不确定时,可以使用*args
和**kwargs
.
*args
是可变的 positional arguments 列表(表示任何多个无名参数,它是一个 tuple)**kwargs
是可变的 keyword arguments 列表(表示关键字参数,它是一个 dict)*args
必须位于**kwargs
之前,因为 positional arguments 必须位于 keyword arguments 之前。
def print_everything(*args):
for count, thing in enumerate(args):
print(("%d. %s" % (count, thing)))
print_everything('apple', 'banana', 'cabbage')
0. apple
1. banana
2. cabbage
def table_things(**kwargs):
for name, value in kwargs.items():
print(name, "=", value)
table_things(apple='fruit', cabbage='vegetable')
apple = fruit
cabbage = vegetable
def foo(*args,**kwargs):
print('args=',args)
print('kwargs=',kwargs)
print(' ')
if __name__=='__main__':
foo(1,2,3,4)
foo(a=1,b=2,c=3)
foo(1,2,3,4,a=1,b=2,c=3)
foo('a',1,None,a=1,b='2',c=3)
args= (1, 2, 3, 4)
kwargs= {}
args= ()
kwargs= {'a': 1, 'b': 2, 'c': 3}
args= (1, 2, 3, 4)
kwargs= {'a': 1, 'b': 2, 'c': 3}
args= ('a', 1, None)
kwargs= {'a': 1, 'b': '2', 'c': 3}
python 可以自动将参数解析后再与调用的函数匹配。
def print_three_things(a, b, c):
print("a =", a, "& b =", b, "& c =", c)
mylist = ['aardvark', 'baboon', 'cat']
print_three_things(*mylist)
a = aardvark & b = baboon & c = cat
# 还有一个很漂亮的用法,就是创建字典:
def kw_dict(**kwargs):
return kwargs
print(kw_dict(a=1, b=2, c=3))
{'a': 1, 'b': 2, 'c': 3}
函数设计原则
-
耦合性
- 对于输入使用参数并且对于输出使用
return
语句:一般地,你需要力求让函数独立于它外部的东西。参数和return
语句通常就是隔离对代码中少数醒目位置的外部依赖关系的最好方法。 - 只有在真正必要的情况下使用全局变量:全局变量可能会引发依赖关系和计时问题,导致程序调试和修改的困难。
- 不要改变可变类型的参数,除非调用者需要这样做:这种耦合性会导致一个函数过于特殊和不友好。
- 对于输入使用参数并且对于输出使用
-
聚合性
- 每一个函数都应该有一个单一的、统一的目标:在设计完美的情况下,每一个函数都应该做一件事:这件事可以用一个简单说明句来总结。(这样做可以提高代码的开发效率)
-
大小
- 每一个函数都应该相对较小:保持简单,提高代码的可读性和扩展性。
-
耦合
- 避免直接改变在另一个模块文件中的变量:提高代码的可读性和扩展性。
简言之,我们应该竭力使函数和其他编程组件中的外部依赖性最小化。函数的自我包含性越好,它就越容易被理解、复用和修改。
递归(程序调用自身的编程技巧)
递归通常把一个大型复杂的问题,转化为一个与原问题相似的,且规模较小的问题来求解。
递归策略只需少量的程序就可以描述出解题的所需的多次重复计算,大大减少了程序的代码量。
递归函数一般包括:
- 当函数直接结束是时有基本的返回值。
- 递归调用函数自身:包括一个或多个对自身函数的调用。
求 \(n!\)
def Factorial(a):
result = a
for i in range(1, a):
result *= i
return result
# 调用函数
print(Factorial(10))
3628800
# 递归函数
def Recursion(a):
if a == 1:
return 1
else:
return a*Recursion(a-1)
Recursion(10)
3628800
求序列的和
def seq_sum(L):
if not L:
return 0
else:
return L[0] + seq_sum(L[1:])
seq_sum([1, 2, 3, 4, 5])
15
为了更好的理解上面的代码,我们可以借助 print
函数:
def seq_sum(L):
print('*'*30)
print(L)
if not L:
s = 0
else:
s = L[0] + seq_sum(L[1:])
print('>'*30)
print(L)
print('-'*30)
print(s)
return s
seq_sum([1, 2, 3, 4, 5])
******************************
[1, 2, 3, 4, 5]
******************************
[2, 3, 4, 5]
******************************
[3, 4, 5]
******************************
[4, 5]
******************************
[5]
******************************
[]
>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
[]
------------------------------
0
>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
[5]
------------------------------
5
>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
[4, 5]
------------------------------
9
>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
[3, 4, 5]
------------------------------
12
>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
[2, 3, 4, 5]
------------------------------
14
>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
[1, 2, 3, 4, 5]
------------------------------
15
15
从打印的结果可以看出:Python 中的递归是以堆栈的形式来实现的。我们可以利用解包技术来进一步简化递归函数:
def seq_sum(L):
return 0 if not L else L[0] + seq_sum(L[1:]) # 仅仅适用数值型
def seq_sum(L):
return L[0] if len(L) == 1 else L[0] + seq_sum(L[1:]) # 适用更多的类型
# 更好的写法
def seq_sum(L):
first, *rest = L
return first if not rest else first + seq_sum(rest)
函数式编程(Functional programming)
参考资料:
什么是函数式编程?
函数式编程使用一系列的函数解决问题。函数仅接受输入并产生输出,不包含任何能影响产生输出的内部状态。任何情况下,使用相同的参数调用函数始终能产生同样的结果。
在一个函数式的程序中,输入的数据“流过”一系列的函数,每一个函数根据它的输入产生输出。函数式风格避免编写有“边界效应”(side effects)的函数:修改内部状态,或者是其他无法反应在输出上的变化。完全没有边界效应的函数被称为“纯函数式的”(purely functional)。避免边界效应意味着不使用在程序运行时可变的数据结构,输出只依赖于输入。
可以认为函数式编程刚好站在了面向对象编程的对立面。对象通常包含内部状态(字段),和许多能修改这些状态的函数,程序则由不断修改状态构成;函数式编程则极力避免状态改动,并通过在函数间传递数据流进行工作。但这并不是说无法同时使用函数式编程和面向对象编程,事实上,复杂的系统一般会采用面向对象技术建模,但混合使用函数式风格还能让你额外享受函数式风格的优点。
为什么使用函数式编程?
函数式的风格通常被认为有如下优点:
- 逻辑可证:
这是一个学术上的优点:没有边界效应使得更容易从逻辑上证明程序是正确的(而不是通过测试)。 - 模块化:
函数式编程推崇简单原则,一个函数只做一件事情,将大的功能拆分成尽可能小的模块。小的函数更易于阅读和检查错误。 - 组件化:
小的函数更容易加以组合形成新的功能。 - 易于调试:
细化的、定义清晰的函数使得调试更加简单。当程序不正常运行时,每一个函数都是检查数据是否正确的接口,能更快速地排除没有问题的代码,定位到出现问题的地方。 - 易于测试:
不依赖于系统状态的函数无须在测试前构造测试桩,使得编写单元测试更加容易。 - 更高的生产率:
函数式编程产生的代码比其他技术更少(往往是其他技术的一半左右),并且更容易阅读和维护。
如何辨认函数式风格?
支持函数式编程的语言通常具有如下特征,大量使用这些特征的代码即可被认为是函数式的:
- 函数是一等公民:
函数能作为参数传递,或者是作为返回值返回。这个特性使得模板方法模式非常易于编写,这也促使了这个模式被更频繁地使用。
以一个简单的集合排序为例,假设lst是一个数集,并拥有一个排序方法sort需要将如何确定顺序作为参数。
如果函数不能作为参数,那么lst的sort方法只能接受普通对象作为参数。这样一来我们需要首先定义一个接口,然后定义一个实现该接口的类,最后将该类的一个实例传给sort方法,由sort调用这个实例的compare方法,就像这样:
函数式编程的起源,是一门叫做范畴论(Category Theory)的数学分支。
理解函数式编程的关键,就是理解范畴论。它是一门很复杂的数学,认为世界上所有的概念体系,都可以抽象成一个个的"范畴"(category)。
1 范畴的概念
"范畴就是使用箭头连接的物体。"彼此之间存在某种关系的概念、事物、对象等等,都构成"范畴"。随便什么东西,只要能找出它们之间的关系,就能定义一个"范畴"。
箭头表示范畴成员之间的关系,正式的名称叫做"态射"(morphism)。范畴论认为,同一个范畴的所有成员,就是不同状态的"变形"(transformation)。通过"态射",一个成员可以变形成另一个成员。
2 数学模型
既然"范畴"是满足某种变形关系的所有对象,就可以总结出它的数学模型。
- 所有成员是一个集合
- 变形关系是函数
也就是说,范畴论是集合论更上层的抽象,简单的理解就是"集合 + 函数"
。
理论上通过函数,就可以从范畴的一个成员,算出其他所有成员。
3 范畴与容器
我们可以把"范畴"想象成是一个容器,里面包含两样东西。
- 值(value)
- 值的变形关系,也就是函数。
本质上,函数式编程只是范畴论的运算方法,跟数理逻辑、微积分、行列式是同一类东西,都是数学方法,只是碰巧它能用来写程序。
在函数式编程中,函数就是一个管道(pipe)。这头进去一个值,那头就会出来一个新的值,没有其他副作用。
函数的合成与柯里化
函数式编程有两个最基本的运算:合成和柯里化。
函数的合成:
如果一个值要经过多个函数,才能变成另外一个值,就可以把所有中间步骤合并成一个函数,这叫做"函数的合成"(compose)。
函数合成就是将那些管道(pipe)连了起来,让数据一口气从多个管道中穿过。
def f(x):
def g():
return x+1
return g()*5
f(3)
20
柯里化:
\(f(x)\) 和\(g(x)\) 合成为 \(f(g(x))\),有一个隐藏的前提,就是 \(f\) 和 \(g\) 都只能接受一个参数。如果可以接受多个参数,比如 \(f(x, y)\) 和 \(g(a, b, c)\),函数合成就非常麻烦。 这时就需要函数柯里化了。
所谓"柯里化",就是把一个多参数的函数,转化为单参数函数。
# 柯里化之前
def add(x, y):
return x + y
add(1, 2)
3
# 柯里化之后
def addX(y):
def f(x):
return x + y
return f
addX(2)(1)
3
有了柯里化以后,我们就能做到,所有函数只接受一个参数。后文的内容除非另有说明,都默认函数只有一个参数,就是所要处理的那个值。
函子
函数不仅可以用于同一个范畴之中值的转换,还可以用于将一个范畴转成另一个范畴。这就涉及到了函子(Functor)。
函子是函数式编程里面最重要的数据类型,也是基本的运算单位和功能单位。
它首先是一种范畴,也就是说,是一个容器,包含了值和变形关系。比较特殊的是,它的变形关系可以依次作用于每一个值,将当前容器变形成另一个容器。
一般约定,函子的标志就是容器具有 map
方法。该方法将容器里面的每一个值,映射到另一个容器。 函子,一般还有有 filter
、reduce
.
filter
用于过滤列表对象,返回真值的原像,类似于逆映射:
def f(x):
return x**2
def even(x):
return x % 2 == 0
print('map(even, range(10)):', list(map(even, range(10))))
print('filter(even, range(15)):', list(filter(even, range(15))))
print('filter(f, range(10)):', list(filter(f, range(10))))
print('map(f,range(10)):', list(map(f,range(10))))
print('map(lambda x:x**2,range(10)):', list(map(lambda x:x**2,range(10))))
map(even, range(10)): [True, False, True, False, True, False, True, False, True, False]
filter(even, range(15)): [0, 2, 4, 6, 8, 10, 12, 14]
filter(f, range(10)): [1, 2, 3, 4, 5, 6, 7, 8, 9]
map(f,range(10)): [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]
map(lambda x:x**2,range(10))): [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]
函数柯里化
一个函数有多个参数,我们希望能固定其中几个参数的值。
from functools import partial
def foo(a, b, c):
return a + b + c
foo2 = partial(foo, b=2)
foo2(a=1, c=3)
6
看上去这跟提供参数默认值的情况类似。但默认值只能固定
为单个值,而柯里化能通过泛化出很多个函数,每个函数用
不同的固定值,来看一下 应用场景
from functools import partial
bin2dec = partial(int, base=2)
hex2dec = partial(int, base=16)
原 int
方法的定义为:int( x[, base])
,base
参数的默认为 10
,经过柯里化之后,可以用如下方式调用:
int('15') #=>15 using default base 10
print(bin2dec('01011')) #=>11
print(hex2dec('67')) #=>103
11
103
反柯里化(Uncurrying)
顾名思义,是柯里化的逆过程。将多个只含单个参数的函数模拟成一个多参数函数。你可以像这样调用:foo(1)(4)
或 (foo(1))(4)
,都能得到正确的结果 5
def foo(a):
def bar(b):
return a + b
return bar
foo(1)(4)
5