Python 函数式编程指南

Table of Contents

1. 什么是函数式编程

  • 一种编程范式,之外还有面向对象(OOP)、面向过程、逻辑式编程
  • 把运算看作是数学中的函数。在一些语言中,没有运算符、操作符,一切都是函数,如 Lisp:(+ 1 1)、(if (> 2 1) t nil) 等等

2. 函数式编程特点

  • 函数是一等公民,可以作为“高阶函数”——把函数作为对象赋值到变量中,还可以把函数对象作为调用的参数以及返回值
  • 引用透明,依赖参数输入,而不是外部变量
  • 依赖函数返回值,一个参数对应唯一一个结果
  • 避免副作用,副作用指会修改外部状态或依赖外部状态
  • 递归作为主要的控制结构

2.1. 不依赖或修改外部状态

“副作用”是指函数执行后会修改函数本身外的状态(比如全局变量),或者是因为外部变量的值从而影响到函数执行结果。比如下面一段依赖外部状态的代码:

is_find = False

def find():
    if xx:
        is_find = True

下面这段代码就不依赖外部变量:

def find():
    if xx:
        return True
    return False

2.1.1. 只依赖参数

下面的代码,依赖外部:

conn = Connection()

def select():
    conn.find(..)

而下面的代码只依赖参数:

def select(conn):
    conn.find(..)

2.1.2. 副作用

有副作用的代码,这段代码直接修改了参数值,并且影响到了外部:

def add_n(lst, n):
    for i in range(len(lst)):
        lst[i] += n

    return lst

a = [1, 2, 3]
print(add_n(a, 5))  # => [6, 7, 8]
print(a)  # => [6, 7, 8]

这段代码也有副作用,因为调用了 print,这会依赖外部 I/O 状态(因为“标准输出”可能被重定向):

def add_5(n):
    print(n)
    return n + 5

2.1.3. 如何解决副作用和外部依赖?

  • 高阶函数,尽可能减少变量
  • 闭包函数,将依赖的状态闭包起来,形成一个独立的私有空间
  • 递归
  • 把核心代码写成函数式,和有副作用的代码隔离开

2.2. 函数式编程的优点

因为函数式编程讲究的是无副作用,这种特性可以很好地完成一些工作,比如:

  • 并行/并发,不依赖外部状态,免去状态竞争;
  • 分布式计算,MapReduce 作为代表;
  • 更简单的单元测试;
  • 事件驱动,为事件传递一个函数对象作为事件触发时调用的回调函数;
  • 热补丁,假如程序某个函数出现 bug,在不中断程序运行的情况下去动态修正函数。目前 Lisp 和 Erlang 支持。

2.3. 其他函数式编程的语言

  • Haskell
  • Erlang
  • Clojure(函数式版Lisp方言,Lisp并不是函数式语言)
  • ……

不是只有函数式语言才能用函数式编程,越来越多的语言加入了函数式特性,如 Python、Ruby、JavaScript、Java 等。

3. 高阶函数

函数一等公民:函数跟其他类型的变量具有同等地位,可以被传来传去,也就说: 函数即可以作为函数的参数传递,也可以作为返回值返回。

3.1. 示例1,map 函数的应用

示例代码如下:

def map_1():
    number_list = range(10)
    return map(lambda n: n ** 2, number_list)

# 同样的功能,用过程式实现如下:
def map_2():
    number_list = range(10)
    result = list()

    for i in number_list:
        result.append(i ** 2)

    return result

注意,在 Python2 中:

map(lambda i: i + 1, range(3))

会立即返回 [1, 2, 3]。而在 Python3 中,map 返回的是一个迭代对象,不会立即执行,要得到相同结果,需要显示调用 list:

list(map(lambda i: i + 1, range(3)))

3.2. 示例2,reduce 函数的应用

def _1_to_100():
    """ 很多人这么写
    """
    n = 0
    for i in range(1, 101):
        n = n + i
    return n


def _1_to_100_1():
    """ 函数式可以这么写
    """
    import operator
    from functools import reduce
    return reduce(operator.add, range(1, 101))

# for Lisp:
# Lisp 中,+就是个函数
# (reduce + (range 1 101))

官方认为绝大多数应该用 for 循环,这样可读性更好,所以从 Python3 内置函数中移除了 reduce 函数,需要从 functools 库中引用:

import functools
functools.reduce(lambda a, b: a + b, range(10))

3.3. 示例3,filter 函数的应用

def odd_p(n):
    """ 判断一个数是否是奇数
    在 Lisp 中,这样的函数叫做谓词函数
    """
    if n % 2 != 0:
        return True
    return False

# 找出 1~10 内的所有奇数
print(filter(odd_p, range(10)))

# for Lisp:
# (filter odd? (range 10))

# 过程式实现如下:
def filter1():
    number_list = range(10)
    result = list()

    for i in number_list:
        if odd_p(i):
            result.append(i)

    return result

3.4. 示例4,偏函数(partial)的应用

import functools


def add(a, b):
    return a + b

add2 = functools.partial(add, 2)
print(add2(1))                  # => 3

偏函数在日志中的应用示例:

import functools

def debug(level, text):
    print('%s: %s' % (level, text))

info = functools.partial(debug, 'INFO')
error = functools.partial(debug, 'ERROR')

3.5. 装饰器

把某个函数加上装饰器后,就可以为函数扩展额外的功能。本质上就是把函数作为参数传递的过程,如:

def show_call_func(func):
    print(func())


@show_call_func
def say_hello():
    return "hello world"

say_hello 函数加上 show_call_func 后,可以在调用完 say_hello 后,打印 say_hello 的返回值。

4. 惰性求值

指需要数据时才返回数据,惰性求值在 Python 可用迭代器(yield)完成:

# 1、不使用迭代器的情况,需要把文件所以行都存入列表中
# 如果文件行数太多,会占用大量内存
def test_1():
    data = list()

    with open('test_data') as f:
        for line in f:
            data.append(line.strip())

    for d in data:
        print(d)


# 2、使用迭代器,需要一行的数据,就只返回一行,不需要全部加载到内存中
def test_2():
    def read_data():
        with open('test_data') as f:
            for line in f:
                yield line.strip()

    for d in read_data():
        print(d)

5. 列表解析

源于 Haskell,它的优点如下:

  • 用来高效创建列表或者遍历迭代器
  • 不用单独创建一个列表,避免副作用

示例:

[i ** 2 for i in range(10)]

Python 还支持 Set 和 Dict 的列表解析:

# 字典的列表解析示例,生成 a~z 的 ASCII 码对应表
{chr(i):i for i in range(97, 123)}

# set 的列表解析示例
{i for i in range(10)}

6. Lambda 表达式/匿名函数

匿名函数既没有函数名的函数对象,一般用在需要函数对象的地方实现一个简短的函数,但又没必要为此专门定义一个新函数,例如:

number_list = range(10)
map(lambda n: n ** 2, number_list)

也可以把 lambda 赋值给一个变量:

n_square = lambda n: n ** 2
n_square(4) # => 16

7. 闭包

闭包可以在函数内部保存状态,例如 Clojure 中的 memoize 可让其他计算量大、较慢的函数具备“缓存”功能,重复调用可直接返回值,而不用再次计算。有了 memoize ,比如在对每行日志的 IP 标记地理位置,重复的 IP 没必要多次检索 IP 数据库。

8. 综合实践,实现 memoize

Clojure 中的 memoize 可以让某个函数具备缓存功能,如果调用该函数时传递了以前用过的参数,就可直接返回结果值,前提是函数一定是没有副作用的,memoize 在函数计算量比较大的情况下非常有用。memoize 函数的用法示例:

(defn a [n] (Thread/sleep n) n)
(time (a 10))                         ; "Elapsed time: 10.27652 msecs"
				      ; => 10

(time (a 1))                          ; "Elapsed time: 1.172558 msecs"
				      ; => 1

(time (a 10))                     ; "Elapsed time: 10.213432 msecs"
				  ; => 10
(def b (memoize a))
(time (b 10))                        ; "Elapsed time: 10.161806 msecs"
				     ; => 10

(time (b 10))                         ; "Elapsed time: 0.086329 msecs"
				      ; => 10

(time (b 10))                         ; "Elapsed time: 0.094688 msecs"
				      ; => 10

现在我们用 Python 实现一个简易版本的,实现前需要考虑:

1、如何给函数包一层有缓存功能的皮?

2、“缓存”的数据结构放哪儿?

涉及的知识点有:闭包、高阶函数、函数无副作用。

完整代码如下:

import time

def memoize(func):
    # 闭包,是为了保存状态
    cache = dict()

    def _cache(args):
        if args in cache:
            return cache[args]

        cache[args] = apply(func, [args])
        return cache[args]

    return _cache


def test(n):
    # 这里有副作用的,其实,只是为了演示
    time.sleep(n)
    return n

def test2(n):
    time.sleep(n * 2)
    print(n)
    return n

a = memoize(test)
b = memoize(test2)

print('a:', a(10))
print('a:', a(10))

print('b:', b(10))
print('b:', b(10))

9. 深入学习