iT邦幫忙

2021 iThome 鐵人賽

DAY 26
0
Software Development

也該是時候學學 Design Pattern 了系列 第 26

Day 26: Behavioral patterns - Strategy

目的

如果物件本身有負責計算的方法,且該方法依照給予的參數,會有不同的計算結果,那可以將計算的部分封裝成獨立的物件,彼此可以互相切換,同時不影響原有的功能。

說明

試想一個情境,物件本身負責計算的功能,像是計算總金額、計算最佳路線、計算事件發生的機率等等,如果給予不同的參數,則計算的結果會大相徑庭。一段時間過後,方法可能會增加許多,多少增加管理上的麻煩。

為了方便管理,可以將計算的部分封裝成獨立的物件,而且物件本身都採用相同的規格,對外有一致的方法。如此一來,物件需要計算時,只要根據參數就能選定相關的計算物件,而且,採用相同的方法,呼叫任意計算物件都可以用相同的方法,簡化物件之間溝通上的麻煩。

在書上,會稱呼計算部分為演算法(Algorithm)。

作法是:

  1. 找出一個物件,其擁有的方法會因為給予的參數不同而有不同的計算結果。(稱作 Context
  2. 建立計算物件的虛擬層親代,負責開規格建立統一的對外窗口。
  3. 將計算部分封裝成獨立的物件,物件本身繼承虛擬層。(稱作 Strategy
  4. Context 本身會儲存目前使用的 Strategy,有計算需求時則使用 Strategy 的統一方法。
  5. 當要切換時,則是切換 Context 儲存的 Strategy

以下範例以「簡易動物園門票收費機」為核心製作。

UML 圖

Strategy Pattern UML Diagram

使用 Java 實作

製作入園者物件:Person

public class Person {
    private String name;
    private boolean hasStudentID;

    public Person(String name, boolean hasStudentID) {
        this.name = name;
        this.hasStudentID = hasStudentID;
    }

    public String getName() {
        return name;
    }

    public boolean isHasStudentID() {
        return hasStudentID;
    }
}

製作計算物件的虛擬層親代:Strategy

public interface Strategy {
    int calculateFees(List<Person> people);
}

製作計算物件的子代:StandardStrategyGroupDiscountStrategy(Strategy 物件)

public class StandardStrategy implements Strategy {
    @Override
    public int calculateFees(List<Person> people) {
        int totalFees = 0;

        for (Person person : people) {
            if (person.isHasStudentID()) {
                totalFees += 30;
            } else {
                totalFees += 60;
            }
        }

        return totalFees;
    }
}

public class GroupDiscountStrategy implements Strategy {
    @Override
    public int calculateFees(List<Person> people) {
        int totalFees = 0;

        for (Person person : people) {
            if (person.isHasStudentID()) {
                totalFees += 30;
            } else {
                totalFees += 60;
            }
        }

        return (int) (totalFees * 0.7);
    }
}

製作動物園門票售票機:ZoomTicketVendingMachine(Context 物件)

public class ZoomTicketVendingMachine {
    private Strategy strategy;
    private List<Person> people;

    public ZoomTicketVendingMachine() {
        people = new ArrayList<>();
    }

    public void setStrategy(int peopleCounts) {
        if (peopleCounts >= 30) {
            strategy = new GroupDiscountStrategy();
        } else {
            strategy = new StandardStrategy();
        }
    }

    public void addPerson(Person person) {
        people.add(person);
    }

    public void removePerson(Person person) {
        people.remove(person);
    }

    public int calculateFees() {
        setStrategy(people.size());
        return strategy.calculateFees(people);
    }

    public void clear() {
        people.clear();
    }
}

測試,模擬一家四口以及校外校學買動物園門票:TicketMachineStrategySample

public class TicketMachineStrategySample {
    public static void main(String[] args) throws Exception {
        ZoomTicketVendingMachine ticketMachine = new ZoomTicketVendingMachine();

        System.out.println("---一家四口,兩大兩小---");
        ticketMachine.addPerson(new Person(NameSelector.exec("m"), false));
        ticketMachine.addPerson(new Person(NameSelector.exec("f"), false));
        ticketMachine.addPerson(new Person(NameSelector.exec("m"), true));
        ticketMachine.addPerson(new Person(NameSelector.exec("f"), true));
        int familyFees = ticketMachine.calculateFees();
        System.out.println("家庭的總金額是: " + familyFees);

        ticketMachine.clear();
        System.out.println("---戶外教學,兩個導師以及三十八個學生---");
        ticketMachine.addPerson(new Person(NameSelector.exec("m"), false));
        ticketMachine.addPerson(new Person(NameSelector.exec("f"), false));

        for (int i = 0; i < 19; i++) {
            ticketMachine.addPerson(new Person(NameSelector.exec("f"), true));
        }

        for (int i = 0; i < 19; i++) {
            ticketMachine.addPerson(new Person(NameSelector.exec("m"), true));
        }

        int schoolTripFees = ticketMachine.calculateFees();
        System.out.println("校外教學的總金額是: " + schoolTripFees);
    }
}

Utils:NameSelector

public class NameSelector {
    private static Random random = new Random();

    public static String exec(String gender) throws IOException, ParseException, Exception {
        // 讀取 JSON Array,內容是字串陣列
        JSONParser parser = new JSONParser();
        Object obj = null;

        if (gender.equals("m")) {
            obj = parser.parse(new FileReader("./src/utils/boyNameList.json"));
        } else if (gender.equals("f")) {
            obj = parser.parse(new FileReader("./src/utils/girlNameList.json"));
        } else {
            throw new Exception("性別代號錯誤!\n輸入的性別參數是: " + gender);
        }

        JSONArray jsonArray = (JSONArray) obj;

        // 0 - 149
        int option = random.nextInt(149 + 0) + 0;
        return (String) jsonArray.get(option);
    }
}

使用 JavaScript 實作

製作入園者物件:Person

class Person {
  /**
   * @param {string} name
   * @param {boolean} hasStudentID
   */
  constructor(name, hasStudentID) {
    this.name = name;
    this.hasStudentID = hasStudentID;
  }

  getName() {
    return this.name;
  }

  isHasStudentID() {
    return this.hasStudentID;
  }
}

製作計算物件的虛擬層親代:Strategy

/**
 * @abstract
 */
class Strategy {
  /**
   * @abstract
   * @param {Person[]} people
   */
  calculateFees(people) { return 0; }
}

製作計算物件的子代:StandardStrategyGroupDiscountStrategy(Strategy 物件)

class StandardStrategy extends Strategy {
  /**
   * @override
   * @param {Person[]} people
   */
  calculateFees(people) {
    let totalFees = 0;

    for (const person of people) {
      if (person.isHasStudentID()) {
        totalFees += 30;
      } else {
        totalFees += 60;
      }
    }

    return totalFees;
  }
}

class GroupDiscountStrategy extends Strategy {
  /**
  * @override
  * @param {Person[]} people
  */
  calculateFees(people) {
    let totalFees = 0;

    for (const person of people) {
      if (person.isHasStudentID()) {
        totalFees += 30;
      } else {
        totalFees += 60;
      }
    }

    return totalFees * 0.7;
  }
}

製作動物園門票售票機:ZoomTicketVendingMachine(Context 物件)

class ZoomTicketVendingMachine {
  constructor() {
    /** @type {Strategy} */
    this.strategy = null;
    /** @type {Person[]} */
    this.people = [];
  }

  /** @param {number} peopleCounts */
  setStrategy(peopleCounts) {
    if (peopleCounts >= 30) {
      this.strategy = new GroupDiscountStrategy();
    } else {
      this.strategy = new StandardStrategy();
    }
  }

  /** @param {Person} person */
  addPerson(person) {
    this.people.push(person);
  }

  /** @param {Person} person */
  removePerson(person) {
    this.people = this.people.filter(item => item !== person);
  }

  calculateFees() {
    this.setStrategy(this.people.length);
    return this.strategy.calculateFees(this.people);
  }

  clear() {
    this.people = [];
  }
}

測試,模擬一家四口以及校外校學買動物園門票:ticketMachineStrategySample

const ticketMachineStrategySample = () => {
  const ticketMachine = new ZoomTicketVendingMachine();

  console.log("---一家四口,兩大兩小---");
  ticketMachine.addPerson(new Person(nameSelector("m"), false));
  ticketMachine.addPerson(new Person(nameSelector("f"), false));
  ticketMachine.addPerson(new Person(nameSelector("m"), true));
  ticketMachine.addPerson(new Person(nameSelector("f"), true));
  const familyFees = ticketMachine.calculateFees();
  console.log("家庭的總金額是: " + familyFees);

  ticketMachine.clear();
  console.log("---戶外教學,兩個導師以及三十八個學生---");
  ticketMachine.addPerson(new Person(nameSelector("m"), false));
  ticketMachine.addPerson(new Person(nameSelector("f"), false));

  for (let i = 0; i < 19; i++) {
    ticketMachine.addPerson(new Person(nameSelector("f"), true));
  }

  for (let i = 0; i < 19; i++) {
    ticketMachine.addPerson(new Person(nameSelector("m"), true));
  }

  const schoolTripFees = ticketMachine.calculateFees();
  console.log("校外教學的總金額是: " + schoolTripFees);
}

ticketMachineStrategySample();

Utils:nameSelector

/** @param {string} gender */
const nameSelector = (gender) => {
  const option = Math.floor(Math.random() * (149 - 0 + 1)) + 0;

  // 讀取 JSON Array,內容是字串陣列
  if (gender === "m") {
    const boyNameList = require('../Sample-by-Java/src/utils/boyNameList.json');
    return boyNameList[option];
  } else if (gender === "f") {
    const girlNameList = require('../Sample-by-Java/src/utils/girlNameList.json');
    return girlNameList[option];
  } else {
    throw new Error(`性別參數錯誤,輸入的參數是: ${gender}`);
  }
}

總結

Strategy 模式跟 Simple Factory Method 十分類似,皆擁有 if - else if - elseswitch case 而有多個可能的選擇,兩者最大的不同在於前者專注在將 Business Logic 抽出;後者專注在如何「產出」需要的物件。當然,兩者的類別不同,理所當然在乎不同的點。

實作上要注意的,與 State 模式相似,什麼樣的情境下,需要將 if - else if - elseswitch case 抽出、封裝成獨立物件?就我工作經驗來說,如果之後在開發上會建立許多負責計算的物件,那可以提早封裝,節省之後要套用 Strategy 模式的時間。反之,如果不會建立許多負責計算的物件,那不用套用 Strategy 模式,維持 if - else if - elseswitch case 也很好。

跟你說,維持if或是switch也不錯

明天將介紹 Behavioural patterns 的第十個模式:Template Method 模式。


上一篇
Day 25: Behavioral patterns - State
下一篇
Day 27: Behavioral patterns - Template Method
系列文
也該是時候學學 Design Pattern 了31

尚未有邦友留言

立即登入留言