通俗 Python 設計模式——享元模式

平田發表於2017-02-03

享元模式是一種用於解決資源和效能壓力時會使用到的設計模式,它的核心思想是通過引入資料共享來提升效能

我們知道程式開發的重點是對現實世界的抽象,那麼相似的物件必然有某些相同的屬性或行為。比如遊戲中,每個角色的均可以做一些相同的動作,同樣型別的角色有更多相同的動作。那麼,出於優化效能減小資源開銷的目的,在應用需要建立大量的計算代價大但共享許多屬性的物件時,可以使用享元。重點在於將不可變(可共享)的屬性與可變的屬性區分開。

下面用書中的實際的程式碼示例來說明。

假設場景,我們需要模擬一片樹林,其中有不同年齡的蘋果樹、梨樹和櫻桃樹分佈在不同的位置。我們先定義Tree這個類,並設定TreeType為幾種樹木的列舉。

from enum import Enum


TreeType = Enum('TreeType', 'apple_tree cherry_tree peach_tree')

class Tree(object):
    def __init__(self, tree_type, age, x, y):
        self.tree_type = tree_type
        self.age = age
        self.x = x
        self.y = y

    def render(self):
        print('型別 {} 年齡 {} 位置 ({}, {})'.format(self.tree_type, self.age, self.x, self.y))

如此我們就成功建立了一個Tree的類,其中的tree_type用於標識每個例項到底是哪一種樹。

但是,這樣一來,我們就會發現,當樹林中樹木非常多的時候,我們的物件的數量將會急劇膨脹,在數量很大或者資源很緊張的時候是不可接受的。所以我們需要考慮使用享元模式來提取通用資料以節約資源。

這裡我們要明確兩個知識點,第一是Python中類屬性與例項屬性的區別,第二是def __new__方法與def __init__方法的區別。具體知識點不是本文討論重點,請各位自行查閱資料學習,這裡直接給出使用這兩個知識點實現享元模式以解決之前方案問題的辦法。

class Tree(object):
    pool = dict()

    def render(self, age, x, y):
        print('型別 {} 年齡 {} 位置 ({}, {})'.format(self.tree_type, age, x, y))

    def __new__(cls, tree_type):
        t = cls.pool.get(tree_type, None)
        if t:
            pass
        else:
            t = object.__new__(cls)
            cls.pool[tree_type] = t
            t.tree_type = tree_type
        return t

Tree類如此修改一番,我們就實現了享元模式。簡單做一個講解。

我們為Tree型別提供了一個類屬性pool代表這個類的物件池——可以理解為一個物件資料的快取,而def __new__方法,將Tree類改造為一個元類,實現了引用自身的目的。於是,在每次建立新的物件時先到pool中檢查是否有該型別的的物件存在,如果存在就直接返回之前建立好的那個物件,如果不存在則在pool中新增這個新的物件,如此一來就實現了物件的複用。這裡要注意的是,我們使用了__new__方法後,移除了__init__方法,並把agexy等可變資料都放到了其他地方,就是為了保留最通用的資料,這也是實現享元模式的核心

下面我們測試一下:

def main():
    import random
    rnd = random.Random()
    age_min, age_max = 1, 30    # 單位為年
    min_point, max_point = 0, 100
    tree_counter = 0

    for _ in range(10):
        t1 = Tree(TreeType.apple_tree)
        t1.render(rnd.randint(age_min, age_max),
                  rnd.randint(min_point, max_point),
                  rnd.randint(min_point, max_point))
        tree_counter += 1

    for _ in range(3):
        t2 = Tree(TreeType.cherry_tree)
        t2.render(rnd.randint(age_min, age_max),
                  rnd.randint(min_point, max_point),
                  rnd.randint(min_point, max_point))
        tree_counter += 1

    for _ in range(5):
        t3 = Tree(TreeType.peach_tree)
        t3.render(rnd.randint(age_min, age_max),
                  rnd.randint(min_point, max_point),
                  rnd.randint(min_point, max_point))
        tree_counter += 1

    print('生成並渲染的樹木: {}棵'.format(tree_counter))
    print('建立的樹木物件: {}個'.format(len(Tree.pool)))

    t4 = Tree(TreeType.cherry_tree)
    t5 = Tree(TreeType.cherry_tree)
    t6 = Tree(TreeType.apple_tree)
    print('{} == {}? {}'.format(id(t4), id(t5), id(t4) == id(t5)))
    print('{} == {}? {}'.format(id(t5), id(t6), id(t5) == id(t6)))
    t4.render(4, 4, 4)
    t5.render(5, 5, 5)
    t6.render(6, 6, 6)

結果輸出如下:

$ python flyweight.py
型別 TreeType.apple_tree 年齡 6 位置 (98, 11)
型別 TreeType.apple_tree 年齡 20 位置 (90, 43)
型別 TreeType.apple_tree 年齡 10 位置 (100, 7)
型別 TreeType.apple_tree 年齡 6 位置 (65, 40)
型別 TreeType.apple_tree 年齡 11 位置 (56, 99)
型別 TreeType.apple_tree 年齡 21 位置 (15, 33)
型別 TreeType.apple_tree 年齡 26 位置 (9, 9)
型別 TreeType.apple_tree 年齡 6 位置 (26, 94)
型別 TreeType.apple_tree 年齡 26 位置 (89, 96)
型別 TreeType.apple_tree 年齡 13 位置 (50, 26)
型別 TreeType.cherry_tree 年齡 28 位置 (17, 37)
型別 TreeType.cherry_tree 年齡 6 位置 (27, 47)
型別 TreeType.cherry_tree 年齡 29 位置 (31, 15)
型別 TreeType.peach_tree 年齡 23 位置 (63, 99)
型別 TreeType.peach_tree 年齡 5 位置 (9, 76)
型別 TreeType.peach_tree 年齡 30 位置 (58, 48)
型別 TreeType.peach_tree 年齡 14 位置 (60, 35)
型別 TreeType.peach_tree 年齡 3 位置 (64, 17)
生成並渲染的樹木: 18棵
建立的樹木物件: 3個
522952945848 == 522952945848? True
522952945848 == 522954153824? False
型別 TreeType.cherry_tree 年齡 4 位置 (4, 4)
型別 TreeType.cherry_tree 年齡 5 位置 (5, 5)
型別 TreeType.apple_tree 年齡 6 位置 (6, 6)

可以看到,最後的記憶體單元編號證明,同樣型別的樹木物件共享了同一個樹木型別資料。但會改變的部分資料——年齡,位置——又互不影響,在樹木數量超大時,將有效提升效能。


最後要注意:享元模式不能依賴物件的id。在上面的測試例項中可以看到,因為使用了享元模式,所以本來是不同的兩棵樹,在做id對比時,卻是同一個物件,這是因為當前一個物件動作完成後,後一個物件就覆蓋了前一個物件。如果不注意,可能會在開發中埋下很大的隱患。要測試也很簡單,我們將Tree類的程式碼改成下面的形式:

from enum import Enum


TreeType = Enum('TreeType', 'apple_tree cherry_tree peach_tree')

class Tree(object):
    tree_type = ''
    pool = dict()

    def __init__(self, tree_type, age):
        self.age = age

    def render(self, x, y):
        print('型別 {} 年齡 {} 位置 ({}, {})'.format(self.tree_type, self.age, x, y))

    def __new__(cls, tree_type, age):
        t = cls.pool.get(tree_type, None)
        if t:
            pass
        else:
            t = object.__new__(cls)
            cls.pool[tree_type] = t
            t.tree_type = tree_type
        return t


def main():
    import random
    rnd = random.Random()
    age_min, age_max = 1, 30    # 單位為年
    min_point, max_point = 0, 100
    tree_counter = 0

    for _ in range(10):
        t1 = Tree(TreeType.apple_tree,
                  rnd.randint(age_min, age_max))
        t1.render(rnd.randint(min_point, max_point),
            rnd.randint(min_point, max_point))
        tree_counter += 1

    for _ in range(3):
        t2 = Tree(TreeType.cherry_tree,
                  rnd.randint(age_min, age_max))
        t2.render(rnd.randint(min_point, max_point),
            rnd.randint(min_point, max_point))
        tree_counter += 1

    for _ in range(5):
        t3 = Tree(TreeType.peach_tree,
                  rnd.randint(age_min, age_max))
        t3.render(rnd.randint(min_point, max_point),
            rnd.randint(min_point, max_point))
        tree_counter += 1

    print('生成並渲染的樹木: {}棵'.format(tree_counter))
    print('建立的樹木物件: {}個'.format(len(Tree.pool)))

    t4 = Tree(TreeType.cherry_tree,
              4)
    t5 = Tree(TreeType.cherry_tree,
              5)
    t6 = Tree(TreeType.apple_tree,
              6)
    print('{} == {}? {}'.format(id(t4), id(t5), id(t4) == id(t5)))
    print('{} == {}? {}'.format(id(t5), id(t6), id(t5) == id(t6)))
    t4.render(4, 4)
    t5.render(5, 5)
    t6.render(6, 6)

if __name__ == '__main__':
    main()

這裡將age放到了__init__方法中,按照設想,他應該成為物件自身的屬性,即每個物件均不同,那麼我們跑一跑程式碼:

$ python flyweight.py
型別 TreeType.apple_tree 年齡 19 位置 (57, 8)
型別 TreeType.apple_tree 年齡 26 位置 (21, 16)
型別 TreeType.apple_tree 年齡 30 位置 (33, 72)
型別 TreeType.apple_tree 年齡 18 位置 (98, 79)
型別 TreeType.apple_tree 年齡 9 位置 (90, 15)
型別 TreeType.apple_tree 年齡 25 位置 (17, 13)
型別 TreeType.apple_tree 年齡 15 位置 (100, 86)
型別 TreeType.apple_tree 年齡 8 位置 (45, 4)
型別 TreeType.apple_tree 年齡 19 位置 (11, 6)
型別 TreeType.apple_tree 年齡 18 位置 (18, 46)
型別 TreeType.cherry_tree 年齡 25 位置 (42, 68)
型別 TreeType.cherry_tree 年齡 9 位置 (8, 50)
型別 TreeType.cherry_tree 年齡 1 位置 (60, 28)
型別 TreeType.peach_tree 年齡 2 位置 (42, 73)
型別 TreeType.peach_tree 年齡 7 位置 (87, 59)
型別 TreeType.peach_tree 年齡 19 位置 (26, 23)
型別 TreeType.peach_tree 年齡 21 位置 (3, 22)
型別 TreeType.peach_tree 年齡 16 位置 (43, 73)
生成並渲染的樹木: 18棵
建立的樹木物件: 3個
332890664464 == 332890664464? True
332890664464 == 332889074432? False
型別 TreeType.cherry_tree 年齡 5 位置 (4, 4)
型別 TreeType.cherry_tree 年齡 5 位置 (5, 5)
型別 TreeType.apple_tree 年齡 6 位置 (6, 6)

最後幾行,在(4, 4)位置的t4櫻桃樹年齡本該是4,卻變成了5,即位置在(5, 5)的t5櫻桃樹的年齡,說明t4中的共享部分資料其實已經被t5所覆蓋。

相關文章