Strategy Pattern

Strategy design pattern  là một technique nhằm mục đích phân tách nhiều vấn đề, tách software modules thông qua Dependency Inversion.

Ý tưởng cơ bản của Strategy pattern là chỉ cần define skeleton ở abstract level, chúng ta tách biệt phần implementation của logic thành nhiều phần. Client sử dụng có thể tự implement 1 số method theo cách riêng của nó nhưng vẫn giữ được cấu trúc của logic workflow gốc.

Abstract class không không phụ thuộc vào implementation của lớp dẫn xuất (derived class), nhưng implementation của lớp dẫn xuất phải tuân thủ theo đặc tả của lớp abstract. Cho nên chúng có tên gọi là Dependency Inversion.

Một thứ mình thấy rõ là các project Rust rất hay sử dụng Strategy Design Pattern này.

Ví dụ, chúng ta có 1 struct Data và implement một số phương thức để generate ra nhiều dạng format khác nhau (ví dụ JSON, YAML, Plain Text, ...). Ta gọi mỗi format ở đây là một strategy.

use std::collections::HashMap;

type Data = HashMap<String, u32>;

impl Data {
  fn generate(&self, format: &str) {
    match format {
      "json" => { ... }
      "yaml" => { ... }
      "text" => { ... }
      _      => { ... }
    }
  }
}

Mọi thứ thay đổi theo thời gian, và khó đoán được trong tương lai chương trình của chúng ta có thể sửa đổi hoặc bổ sung thêm các loại format nào nữa trong tương lai hay không (ví dụ JSONLine, CSV, Parquet, ...)

Nếu thiết kế sử dụng Strategy Pattern:

use std::collections::HashMap;

// Data
type Data = HashMap<String, u32>;
impl Data {
  // f: T chap nhan moi struct co impl Formatter
  fn generate<T: Formatter>(f: T) -> String {
    f.format(&self)
  }
}

// Formatter
trait Formatter {
  fn format(&self, data: &Data) -> String;
}

// Formatter -> Json
struct Json;
impl Formatter for Json {
  fn format(&self, data: &Data) -> String {
    // res = { "a": 1, "b": 2. /// }
    res
  }
}

// Formatter -> Text
struct Text;
impl Formatter for Text {
  fn format(&self, data: &Data) -> String {
    // res = "a = 1, b = 2, ..."
    res
  }
}

fn main() {
  let mut data = Data::new();
  data.insert("a".to_string(), 1);
  data.insert("b".to_string(), 2);

  let s = data.generate(Text);
  assert!(s.contains("a = b, b = 2"));

  let s = data.generate(Json);
  assert!(s.contains(r#"{"a":1, "b":2}"#));
}

Theo chúng ta có thể thấy, Data::generate có thể không cần quan tâm implementation của f: T. Chỉ cần biết nó là một dẫn xuất của trait Formatter và có method format.

Nhược điểm là mỗi strategy cần được implement ít nhất một module, vì thế số lượng module có thể tăng cùng với số lượng strategy. Có quá nhiều strategy đòi hỏi user phải biết sự khác nhau giữa các strategy để sử dụng.

Ưu điểm là chúng ta có thể tách việc xử lý Json, Text, ... ra thành nhiều bài toán (strategy) nhỏ hơn theo như ví dụ trên.

Ở ví dụ trên các strategy được đặt chung ở một file, thực tế người ta thưởng đặt ở nhiều module khác nhau hoặc mỗi strategy một file (formatter::json, formatter::csv, ...). Việc tách này còn cho phép sử dụng compiler feature flags.

Còn nếu chúng ta đang implement một crate, thì crate ở ví dụ trên user có thể dễ dàng custom một Formatter mới:

use crate::example::{Data, Formatter};

struct CustomFormatter;

impl Formatter for CustomFormatter {
  fn format(&self, data: &Data) -> String {
    ...
  }
}

serde là một ví dụ hay của Strategy pattern, serde cho phép full customization serialization behavior bằng cách implement Serialize và Deserialize traits cho kiểu dữ liệu riêng của chúng ta.