Python函数式编程:惰性求值

2023年 7月 12日 18.1k 0

缘起

我们回到介绍高阶函数的一章,我们提到了高阶函数特别是科里化的一个好处便是「提前求值」和「推迟求值」,通过这些操作,我们可以大大优化很多代码。比如,我们使用之前的例子:

def f(x): # x储存了某种我们需要的状态
    ## 所有可以提前计算的放在这里
    z = x ** 2 + x + 1
    print('z is {}'.format(z))
    def helper(y):
        ## 所有延迟计算的放在这里
        return y * z
    return helper

我们在调用f(1)的时候,其实就已经事先计算了z的部分,如果我们临时保存这个值,反复调用时就可以节省很大的时间:

>>> g = f(1)
z is 3
>>> g(2) + g(1) # 可以看到这次就不会打印`z is xxxx`的输出了
9

也就是说适时的「提前求值」和「推迟求值」都可以帮助我们大大地减少很多运算开销。这就引入我们这一篇要讲的「惰性求值」的概念,惰性求值的概念主要是:调用时才计算,并且只计算一次。

惰性属性与惰性值

我们考虑下面一个例子:

定义一个圆的类,通过圆心和半径来描述,但是当我们知道圆心和半径之后我们能知道很多事,比如:

  • 周长(perimeter)
  • 面积(area)
  • 圆最上面坐标的位置(upper_point)
  • 圆心到原点的距离(distance_from_origin)
  • ...
  • 这个列表可能非常非常多,而且随着软件功能的增加,这个列表可能还会添加。我们可能有两种方法实现。第一种就是在初始化的时候都给设定为圆的属性:

    @dataclass
    class CircleInitial:
        x: float
        y: float
        r: float
    
        def __init__(self, x, y, r):
            self.x = x
            self.y = y
            self.r = r
    
            self.perimeter = 2 * r
            self.area = r * r * 3.14
            self.upper_point = (x, y + r)
            self.lower_point = (x, y - r)
            self.left_point = (x - r, y)
            self.right_point = (x + r, y)
            self.distance_from_origin = (x ** 2 + y ** 2) ** (1/2)

    我们马上可以看出问题:如果这样的属性非常多,而且涉及的计算也非常多的话,那么当我们实例化一个新的对象的时候,耗费的时间将会非常长。然而,大部分的属性,我们可能都不会用到。

    于是,就有了第二个方案,把这些实现成一个方法(我们这里仅举例一个area方法):

    @dataclass
    class CircleMethod:
        x: float
        y: float
        r: float
    
        def area(self):
            print("area calculating...")
            return self.r * self.r * 3.14

    当然,因为这个值是一个「常」量的概念,我们也可以使用property修饰器,这样我们就可以不用带括号地调用它了:

    @dataclass
    class CircleMethod:
        x: float
        y: float
        r: float
    
        @property
        def area(self):
            print("area calculating...")
            return self.r * self.r * 3.14

    我故意在其中加入了一行打印代码,我们可以发现,我们每次调用area时,都会被计算一次:

    >>> a = CircleMethod(1, 2, 3)
    >>> a.area ** 2 + a.area + 1
    area calculating...
    area calculating...
    827.8876000000001

    这又是另外一种浪费了,于是我们发现,第一种方案适合需要经常被反复调用的属性,第二个方案实现很少被调用的属性。但是,可能我们在维护代码的时候,没法事先预判一个属性是不是经常被调用,而且这也不是一个长久之计。但我们发现我们需要的就是那么一个属性:

  • 这个属性不会初始化的时候计算
  • 这个属性只在被调用时计算
  • 这个属性只会计算一次,后面不会调用
  • 这个就是「惰性求值」的概念,我们也把这种属性叫「惰性属性」。Python没有内置的惰性属性的概念,不过,我们可以很容易从网上找到一个实现(你也可以在我的Python-functional-programming中的lazy_evaluate.py中找到):

    def lazy_property(func):
        attr_name = "_lazy_" + func.__name__
    
        @property
        def _lazy_property(self):
            if not hasattr(self, attr_name):
                setattr(self, attr_name, func(self))
            return getattr(self, attr_name)
    
        return _lazy_property

    具体的使用,只是切换一下修饰器property

    @dataclass
    class Circle:
        x: float
        y: float
        r: float
    
        @lazy_property
        def area(self):
            print("area calculating...")
            return self.r * self.r * 3.14

    我们采用和上面一样的调用方式,可以发现,area只计算了一次(只打印了一次):

    >>> b = Circle(1, 2, 3)
    >>> b.area ** 2 + b.area + 1
    area calculating...
    827.8876000000001

    同样的理由我们也可以实现一个惰性值的概念,不过因为python没有代码块的概念,我们只能用没有参数的函数来实现:

    class _LazyValue:
    
        def __setattr__(self, name, value):
            if not callable(value) or value.__code__.co_argcount > 0:
                raise NotVoidFunctionError("value is not a void function")
            super(_LazyValue, self).__setattr__(name, (value, False))      
            
        def __getattribute__(self, name: str):
            try:
                _func, _have_called = super(_LazyValue, self).__getattribute__(name)
                if _have_called:
                    return _func
                else:
                    res = _func()
                    super(_LazyValue, self).__setattr__(name, (res, True))
                    return res
            except:
                raise AttributeError(
                    "type object 'Lazy' has no attribute '{}'"
                    .format(name)
                )
    
    lazy_val = _LazyValue()

    具体调用方法如下,如果你要设计一个模块而这个变量不在类中,那么就可以很方便地使用它了:

    def f():
        print("f compute")
        return 12
    
    >>> lazy_val.a = f
    >>> lazy_val.a
    f compute
    12
    >>> lazy_val.a
    12

    惰性迭代器/生成器

    此外,Python内置了一些惰性的结构主要就是迭代器和生成器,我们可以很方便验证它们只计算/保留一次(这里只验证迭代器):

    >>> a = (i for i in range(5))
    >>> list(a)
    [0, 1, 2, 3, 4]
    >>> list(a)
    []

    我们可以设计下面两个函数:

    def f(x):
        print("f")
        return x + 1
    
    def g(x):
        print("g")
        return x + 1

    然后我们思考下面的结果:

    >>> a = (g(i) for i in (f(i) for i in range(5)))
    >>> next(a)

    它可能有两种结果,一个它可能的计算方式是这样的:

    >>> temp = [f(i) for i in range(5)]
    >>> res = g(temp[0])

    如果是这种结果,则它会打印出5个f然后再打印出g

    另一种可能性则是:

    >>> res = (g(f(i)) for i in range(5))

    则,这样子便只会打印一个f和一个g。如果根据惰性求值的定义,i=1并没有被真实调用,所以它应该不用求值,所以,如果他符合第二个打印情况,则它就是惰性的对象。事实也就真如此。

    当然,这个特性已经非常的Fancy了,但是我们基于此可以联想出的一个非常奇妙的引用,因为在迭代器计算中,我们并不是在生成的时候,就计算出了迭代器中的每个值,因此,我们可以用这个方式存储一个无穷系列。通过上面的方式计算后返回结果。一个最简单的例子是内置模块中的itertools.repeat,我们可以生成一个无穷的全为1的线性结构:

    from itertools import repeat
    
    repeat_1 = repeat(1)

    这样,我们就可以用上面的列表表达式来做一些计算再通过next调用了。

    res = (g(i) for i in (i * 3 for i in repeat_1))
    next(res)

    我们也将这些线性结构称为「惰性列表」(这里的repeat_1则是一个「无穷惰性列表」的例子),在下面的文章中,我们将详细地用这个方式来完成一些有趣的事情。

    相关文章

    JavaScript2024新功能:Object.groupBy、正则表达式v标志
    PHP trim 函数对多字节字符的使用和限制
    新函数 json_validate() 、randomizer 类扩展…20 个PHP 8.3 新特性全面解析
    使用HTMX为WordPress增效:如何在不使用复杂框架的情况下增强平台功能
    为React 19做准备:WordPress 6.6用户指南
    如何删除WordPress中的所有评论

    发布评论