Python函數語言程式設計系列007:惰性求值

三次方根發表於2021-10-12
本系列文章一些重要的函式、方法、類我都實現的一遍,你可以在github(點選此處)中找到程式碼和測試例子(如果網速過慢我也放了一份在gitee(點選此處)上,但請勿在gitee上提issue或者留言),歡迎star/fork

緣起

我們回到介紹高階函式的一章,我們提到了高階函式特別是科裡化的一個好處便是「提前求值」和「推遲求值」,通過這些操作,我們可以大大優化很多程式碼。比如,我們使用之前的例子:

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

也就是說適時的「提前求值」和「推遲求值」都可以幫助我們大大地減少很多運算開銷。這就引入我們這一篇要講的「惰性求值」的概念,惰性求值的概念主要是:呼叫時才計算,並且只計算一次。

惰性屬性與惰性值

我們考慮下面一個例子:

定義一個圓的類,通過圓心和半徑來描述,但是當我們知道圓心和半徑之後我們能知道很多事,比如:

  1. 周長(perimeter)
  2. 面積(area)
  3. 圓最上面座標的位置(upper_point)
  4. 圓心到原點的距離(distance_from_origin)
  5. ...

這個列表可能非常非常多,而且隨著軟體功能的增加,這個列表可能還會新增。我們可能有兩種方法實現。第一種就是在初始化的時候都給設定為圓的屬性:

@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

這又是另外一種浪費了,於是我們發現,第一種方案適合需要經常被反覆呼叫的屬性,第二個方案實現很少被呼叫的屬性。但是,可能我們在維護程式碼的時候,沒法事先預判一個屬性是不是經常被呼叫,而且這也不是一個長久之計。但我們發現我們需要的就是那麼一個屬性:

  1. 這個屬性不會初始化的時候計算
  2. 這個屬性只在被呼叫時計算
  3. 這個屬性只會計算一次,後面不會呼叫

這個就是「惰性求值」的概念,我們也把這種屬性叫「惰性屬性」。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則是一個「無窮惰性列表」的例子),在下面的文章中,我們將詳細地用這個方式來完成一些有趣的事情。

相關文章