設計模式系列7--組合模式

西木柚子發表於2016-12-06

場景分析

我們平時去餐廳吃飯,都會使用選單來點餐,今天我們來實現一個超級選單,這個一個選單大集合,包括單一菜品和子選單,如圖所示:

設計模式系列7--組合模式
image

可以看到上面的選單不但包括單個的菜品專案,還包括子選單專案,子選單也包含一系列菜品或者子選單。

我們現在想實現兩個個需求:

  • 如果是選單專案,我們需要列印選單的名稱和描述,新增刪除子選單或者菜品,列印所有子選單、子選單包括的菜品、子選單的子選單的名稱和描述,一直遞迴列印到最後一個菜品專案。
  • 如果是菜品專案,我們需要得到菜品的價格、描述、名稱、是否是素菜這些資訊

可以發現上述兩個需求有相同和不同的地方,常規做法就是區別對待兩者各自進行操作。但是這樣以後擴充套件起來就非常麻煩,如果新增或者刪除兩者,那麼原有程式碼就需要做相應的修改。而且兩者其實很多操作都是類似的,卻要寫兩套程式碼,操作繁瑣。

分析下上面的圖,我們不難發現這是一個典型的樹形結構圖,菜品專案是葉節點,子選單專案是子節點(子節點還可以包含子節點或者葉節點),所有選單是根節點,這個結構可以無限延伸下去。

如果我們能統一對待葉節點和子節點,使用一致的方式在樹結構間遊走處理,那就方便許多,這樣不管以後新加一個葉節點還是子節點,原有程式碼都不需要修改,因為他們二者的處理方式完全一致。

下面我們就來看看具體的實現。


程式碼實現

1、定義葉節點和子節點的父類

先定義一個抽象類,作為葉節點和子節點的父類,父類定義了兩者的所有操作方法,兩者可以自己選擇實現自己需要的方法。父類的每個方法預設實現都是丟擲異常,等待子類覆蓋實現。如果子類沒有覆蓋,然後又呼叫了該方法,就會丟擲異常。

#import <Foundation/Foundation.h>

@interface MenuComponent : NSObject
-(void)add:(MenuComponent *)component;
-(void)remove:(MenuComponent *)component;
-(MenuComponent*)getChild:(NSInteger)position;
-(NSString*)getName;
-(NSString*)getDescription;
-(CGFloat)getPrice;
-(BOOL)isVegetarian;
-(void)print;
@end


===============
#import "MenuComponent.h"

@implementation MenuComponent
-(void)add:(MenuComponent *)component{
    NSString *reason = [NSString stringWithFormat:@"【%@】沒有實現該方法",NSStringFromClass([self class])];
    @throw ([NSException exceptionWithName:@"不支援該方法" reason:reason userInfo:nil]);
}

-(void)remove:(MenuComponent *)component{
    NSString *reason = [NSString stringWithFormat:@"【%@】沒有實現該方法",NSStringFromClass([self class])];
    @throw ([NSException exceptionWithName:@"不支援該方法" reason:reason userInfo:nil]);
}

-(MenuComponent *)getChild:(NSInteger)position{
    NSString *reason = [NSString stringWithFormat:@"【%@】沒有實現該方法",NSStringFromClass([self class])];
    @throw ([NSException exceptionWithName:@"不支援該方法" reason:reason userInfo:nil]);
}

-(NSString *)getName{
    NSString *reason = [NSString stringWithFormat:@"【%@】沒有實現該方法",NSStringFromClass([self class])];
    @throw ([NSException exceptionWithName:@"不支援該方法" reason:reason userInfo:nil]);
}

-(NSString *)getDescription{
    NSString *reason = [NSString stringWithFormat:@"【%@】沒有實現該方法",NSStringFromClass([self class])];
    @throw ([NSException exceptionWithName:@"不支援該方法" reason:reason userInfo:nil]);
}

-(CGFloat)getPrice{
    NSString *reason = [NSString stringWithFormat:@"【%@】沒有實現該方法",NSStringFromClass([self class])];
    @throw ([NSException exceptionWithName:@"不支援該方法" reason:reason userInfo:nil]);
}

-(BOOL)isVegetarian{
    NSString *reason = [NSString stringWithFormat:@"【%@】沒有實現該方法",NSStringFromClass([self class])];
    @throw ([NSException exceptionWithName:@"不支援該方法" reason:reason userInfo:nil]);
}

-(void)print{
    NSString *reason = [NSString stringWithFormat:@"%@沒有實現該方法",NSStringFromClass([self class])];
    @throw ([NSException exceptionWithName:@"不支援該方法" reason:reason userInfo:nil]);
}


@end複製程式碼

2、實現選單專案

#import "MenuComponent.h"

@interface Menu : MenuComponent
@property(copy ,nonatomic)NSString *name;
@property(copy ,nonatomic)NSString *desc;
@property(strong,nonatomic)NSMutableArray<MenuComponent *>* menuComponentArr;

-(instancetype)initMenuItemWithName:(NSString*)name withDesc:(NSString*)desc;
@end

========================================

#import "Menu.h"

@implementation Menu
-(instancetype)initMenuItemWithName:(NSString *)name withDesc:(NSString *)desc{
    if (self == [super init]) {
        self.name = name;
        self.desc = desc;
        self.menuComponentArr = [NSMutableArray array];

    }

    return self;
}

-(NSString *)getDescription{
    return self.desc;
}

-(NSString *)getName{
    return self.name;
}


-(void)add:(MenuComponent *)component{
    [self.menuComponentArr addObject:component];
}

-(void)remove:(MenuComponent *)component{
    [self.menuComponentArr enumerateObjectsUsingBlock:^(MenuComponent * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
        if(obj == component){
            [self.menuComponentArr removeObject:component];
        }else{
            if ([obj isKindOfClass:[Menu class]]) {
                if ([((Menu *)obj).menuComponentArr containsObject:component]) {
                    [obj remove:component];
                    }
                }
            }
    }];


}


-(MenuComponent*)getChild:(NSInteger)position{
    return self.menuComponentArr[position];
}


-(void)print{
    NSLog(@"選單名稱:%@ | 選單描述:%@ " ,self.name, self.desc);
    if(self.menuComponentArr.count){
        for (MenuComponent * component in self.menuComponentArr) {
            [component print];
        }
    }
}


@end複製程式碼

3、實現菜品專案

#import "MenuComponent.h"

@interface menuItem : MenuComponent
@property(copy ,nonatomic)NSString *name;
@property(copy ,nonatomic)NSString *desc;
@property(assign,nonatomic)NSInteger isVegetarain;
@property(assign,nonatomic)CGFloat price;

-(instancetype)initMenuItemWithName:(NSString*)name withDesc:(NSString*)desc withVegetarain:(NSInteger)isVege withPrice:(CGFloat)price;

@end

=====================================================

#import "menuItem.h"

@implementation menuItem
-(instancetype)initMenuItemWithName:(NSString *)name withDesc:(NSString *)desc withVegetarain:(NSInteger)isVege withPrice:(CGFloat)price{
    if (self == [super init]) {
        self.name = name;
        self.desc = desc;
        _isVegetarain = isVege;
        self.price = price;

    }

    return self;
}

-(CGFloat)getPrice{
    return self.price;
}

-(NSString *)getDescription{
    return self.desc;
}

-(NSString *)getName{
    return self.name;
}

-(BOOL)isIsVegetarain{
    return self.isVegetarain;
}

-(void)print{
    NSLog(@"菜品名稱:%@ | 菜品價格:%f | 菜品描述:%@ | 是否是素菜:%@" ,self.name, self.price, self.desc, self.isVegetarain ? @"是":@"不是");
}

@end複製程式碼

4、客戶端除錯

我們先按照文章開頭的圖完成選單的構建

MenuComponent *pancakeHouseMenu = [[Menu alloc]initMenuItemWithName:@"博餅屋選單" withDesc:@"早餐"];
        MenuComponent *dinnerMenu = [[Menu alloc]initMenuItemWithName:@"正餐選單" withDesc:@"午餐"];
        MenuComponent *cafeMenu = [[Menu alloc]initMenuItemWithName:@"咖啡選單" withDesc:@"晚餐"];
        MenuComponent *dessertMenu = [[Menu alloc]initMenuItemWithName:@"甜點選單" withDesc:@"飯後甜點"];
        MenuComponent *allMenu = [[Menu alloc]initMenuItemWithName:@"所有選單" withDesc:@"所有選單的組合"];

        [allMenu add:pancakeHouseMenu];
        [allMenu add:dinnerMenu];
        [allMenu add:cafeMenu];

        menuItem *meatItem = [[menuItem alloc]initMenuItemWithName:@"紅燒肉" withDesc:@"祖傳紅燒肉,肥而不膩" withVegetarain:0 withPrice:177.2f];
        menuItem *fishItem = [[menuItem alloc]initMenuItemWithName:@"清蒸鱸魚" withDesc:@"新鮮味美,回味無窮" withVegetarain:0 withPrice:2332.0f];
        [dinnerMenu add:meatItem];
        [dinnerMenu add:fishItem];

        menuItem *dessertItem1 = [[menuItem alloc]initMenuItemWithName:@"清炒小白菜" withDesc:@"味美而鮮,有機綠色無汙染" withVegetarain:1 withPrice:17.3f];
        menuItem *dessertItem2 = [[menuItem alloc]initMenuItemWithName:@"玉米排骨湯" withDesc:@"飯後一口湯,快樂似神仙" withVegetarain:1 withPrice:243.3f];
        [dessertMenu add:dessertItem1];
        [dessertMenu add:dessertItem2];
        [dinnerMenu add:dessertMenu];複製程式碼

此時我們列印一下所有選單,只需要一句命令就可以列印出所有的菜品專案和子選單專案

[allMenu print];複製程式碼

輸出如下:

2016-12-04 19:22:12.569 組合模式[39987:657657] 選單名稱:所有選單 | 選單描述:所有選單的組合 
2016-12-04 19:22:12.570 組合模式[39987:657657] 選單名稱:博餅屋選單 | 選單描述:早餐 
2016-12-04 19:22:12.570 組合模式[39987:657657] 選單名稱:正餐選單 | 選單描述:午餐 
2016-12-04 19:22:12.570 組合模式[39987:657657] 菜品名稱:紅燒肉 | 菜品價格:177.199997 | 菜品描述:祖傳紅燒肉,肥而不膩 | 是否是素菜:不是
2016-12-04 19:22:12.570 組合模式[39987:657657] 菜品名稱:清蒸鱸魚 | 菜品價格:2332.000000 | 菜品描述:新鮮味美,回味無窮 | 是否是素菜:不是
2016-12-04 19:22:12.570 組合模式[39987:657657] 選單名稱:甜點選單 | 選單描述:飯後甜點 
2016-12-04 19:22:12.571 組合模式[39987:657657] 菜品名稱:清炒小白菜 | 菜品價格:17.299999 | 菜品描述:味美而鮮,有機綠色無汙染 | 是否是素菜:是
2016-12-04 19:22:12.571 組合模式[39987:657657] 菜品名稱:玉米排骨湯 | 菜品價格:243.300003 | 菜品描述:飯後一口湯,快樂似神仙 | 是否是素菜:是
2016-12-04 19:22:12.571 組合模式[39987:657657] 選單名稱:咖啡選單 | 選單描述:晚餐複製程式碼

此時我們試著刪除dessertMenu,然後再次列印選單

2016-12-04 19:22:12.571 組合模式[39987:657657] 選單名稱:所有選單 | 選單描述:所有選單的組合 
2016-12-04 19:22:12.571 組合模式[39987:657657] 選單名稱:博餅屋選單 | 選單描述:早餐 
2016-12-04 19:22:12.571 組合模式[39987:657657] 選單名稱:正餐選單 | 選單描述:午餐 
2016-12-04 19:22:12.571 組合模式[39987:657657] 菜品名稱:紅燒肉 | 菜品價格:177.199997 | 菜品描述:祖傳紅燒肉,肥而不膩 | 是否是素菜:不是
2016-12-04 19:22:12.571 組合模式[39987:657657] 菜品名稱:清蒸鱸魚 | 菜品價格:2332.000000 | 菜品描述:新鮮味美,回味無窮 | 是否是素菜:不是
2016-12-04 19:22:12.571 組合模式[39987:657657] 選單名稱:咖啡選單 | 選單描述:晚餐複製程式碼

可以看到移除成功。

如果我們試著對dinnerMenu這個子選單專案呼叫如下方法

[dinnerMenu isVegetarian];複製程式碼

會發現直接崩潰報錯如下:

2016-12-04 20:40:44.049 組合模式[40371:710191] *** Terminating app due to uncaught exception '不支援該方法', reason: '【Menu】沒有實現該方法'複製程式碼

此處就涉及到一個取捨問題:透明性和安全性誰更重要?

上面的例子就是保證了透明性,讓子節點和葉節點被統一對待,如果呼叫了二者不支援的方法就直接丟擲異常。安全性就需要對呼叫者做一個判斷,如果呼叫者呼叫了錯誤的方法就不執行,這樣保證不會丟擲異常,但是需要區別呼叫者。

我們使用組合模式的意圖就是為了保持葉節點和子節點的一致性,所以一般更偏重於透明性而不是安全性。

通過上面的例子大家應該對組合模式有了一個感性的認識,那麼現在我們來具體看看組合模式的定義


定義

將 對 象 組 合 成 樹 形 結 構 以 表 示 “ 部 分 -整 體 ” 的 層 次 結 構 。 組合模式 使 得 用 戶 對 單 個 對 象 和組合物件的使用具有一致性。

組合模式的目的就是讓客戶端不用區分操作的物件是子節點還是葉節點,而是用一種統一的方式來操作。

實現這個目標的關鍵在於,設計一個抽象的元件類,讓它可以程式碼子節點和葉節點,這樣客戶端就不需要區分二者,統一操作它們即可。

通常,組合模式都是用樹形結構來表示的,通過根節點、子節點、葉節點組合成一顆物件樹,這也意味著任何可以使用物件樹來描述或者操作的功能,都可以使用組合模式來進行,比如XML解析,層次結構的選單,iOS中由多個子檢視構成的複雜檢視,這些都可以使用組合模式來實現。

同時要注意,因為要讓客戶端統一操作子節點和葉節點,那麼他們的抽象類就必須定義包含二者的所有方法,如果呼叫者呼叫了它們不支援的方法,可以丟擲異常警告。這雖然違反了單一原則,但是在實際開發中是合理的。


好處

  • 定義了包含基本物件和組合物件的類層次結構

    基本物件可以被組合成更復雜的組合對
    象,而這個組合物件又可以被組合,這樣不斷的遞迴下去。客戶程式碼中,任何用到基本
    物件的地方都可以使用組合物件。

  • 簡化客戶程式碼

    客戶可以一致地使用組合結構和單個物件。通常使用者不知道 (也不關心)處理的是一個葉節點還是一個組合元件。這就簡化了客戶程式碼 , 因為在定義組合的那些類中不需要寫一些充斥著選擇語句的函式。

  • 使 得 更 容 易 增 加 新 類 型 的 組 件

    新定義的 C o m p o s i t e 或 L e a f 子 類 自 動 地 與 已 有 的 結 構 和 客
    戶程式碼一起工作,客戶程式不需因新的 C o m p o n e n t類而改變。

  • 使你的設計變得更加一般化

    容易增加新元件也會產生一些問題,那就是很難限制組合
    中的元件。有時你希望一個組合只能有某些特定的元件。使用 C o m p o s i t e 時,你不能依賴型別系統施加這些約束,而必須在執行時刻進行檢查。


使用時機

  • 如果你想表示物件的部分--整體層次結構。可以選用組合模式把整體和部分統一起來,使得層次結構實現更簡單,從外部使用這個層次結構也更容易。

  • 如果你希望統一的使用組合結構中的所有物件,這正是組合模式提供的主要功能


Demo下載

組合模式Demo

相關文章