0

Bài 2: Consider a builder when faced with many constructor parameters

Static factory và constructor có chung một hạn chế: chúng không mở rộng tốt khi số lượng tham số tùy chọn (optional parameters) trở nên quá lớn. Hãy xét trường hợp một class biểu diễn nhãn Nutrition Facts xuất hiện trên các thực phẩm đóng gói. Những nhãn này có một vài trường bắt buộc (required fields) — khẩu phần ăn (serving size), số khẩu phần trong mỗi hộp (servings per container), và lượng calo trên mỗi khẩu phần (calories per serving) — cùng với hơn hai mươi trường tùy chọn (optional fields) như tổng lượng chất béo (total fat), chất béo bão hòa (saturated fat), chất béo chuyển hóa (trans fat), cholesterol, natri (sodium), v.v. Phần lớn sản phẩm chỉ có giá trị khác 0 ở một vài trường tùy chọn trong số đó.

Vậy bạn nên viết constructor hoặc static factory như thế nào cho một class như vậy? Theo cách truyền thống, các lập trình viên thường sử dụng telescoping constructor pattern, trong đó bạn cung cấp một constructor chỉ nhận các tham số bắt buộc, một constructor khác nhận thêm một tham số tùy chọn, constructor thứ ba nhận thêm hai tham số tùy chọn, và cứ tiếp tục như vậy cho đến constructor cuối cùng nhận toàn bộ các tham số tùy chọn. Dưới đây là cách tiếp cận này được sử dụng trong thực tế. Để ngắn gọn, ví dụ chỉ hiển thị bốn trường tùy chọn:

// Telescoping constructor pattern - does not scale well!
public class NutritionFacts {
    private final int servingSize;  // (mL)            required
    private final int servings;     // (per container) required
    private final int calories;     // (per serving)   optional
    private final int fat;          // (g/serving)     optional
    private final int sodium;       // (mg/serving)    optional
    private final int carbohydrate; // (g/serving)     optional

    public NutritionFacts(int servingSize, int servings) {
        this(servingSize, servings, 0);
    }

    public NutritionFacts(int servingSize, int servings, int calories) {
        this(servingSize, servings, calories, 0);
    }

    public NutritionFacts(int servingSize, int servings, int calories, int fat) {
        this(servingSize, servings, calories, fat, 0);
    }

    public NutritionFacts(int servingSize, int servings, int calories, int fat, int sodium) {
        this(servingSize, servings, calories, fat, sodium, 0);
    }

    public NutritionFacts(int servingSize, int servings, int calories, int fat, int sodium, int carbohydrate) {
        this.servingSize  = servingSize;
        this.servings     = servings;
        this.calories     = calories;
        this.fat          = fat;
        this.sodium       = sodium;
        this.carbohydrate = carbohydrate;
    }
}

Khi bạn muốn tạo một instance, bạn sử dụng constructor với danh sách tham số ngắn nhất chứa tất cả các tham số bạn muốn đặt:

NutritionFacts cocaCola = new NutritionFacts(240, 8, 100, 0, 35, 27);

Thông thường, lời gọi constructor theo cách này sẽ yêu cầu rất nhiều tham số mà bạn không hề muốn thiết lập, nhưng vẫn buộc phải truyền giá trị cho chúng. Trong trường hợp này, chúng ta truyền giá trị 0 cho trường fat. Với “chỉ” sáu tham số, điều này có vẻ chưa quá tệ, nhưng nó sẽ nhanh chóng trở nên mất kiểm soát khi số lượng tham số tăng lên.

Tóm lại, telescoping constructor pattern hoạt động được, nhưng việc viết client code trở nên khó khăn khi có quá nhiều tham số, và việc đọc hiểu còn khó hơn nữa. Người đọc phải tự hỏi tất cả những giá trị đó đại diện cho điều gì và phải cẩn thận đếm các tham số để tìm ra câu trả lời. Những chuỗi dài các tham số có cùng kiểu dữ liệu còn có thể gây ra những bug rất khó phát hiện. Nếu client vô tình đảo vị trí hai tham số như vậy, compiler sẽ không báo lỗi, nhưng chương trình sẽ hoạt động sai ở runtime (Item 51).

Một giải pháp thay thế thứ hai khi phải đối mặt với nhiều tham số tùy chọn trong constructor là JavaBeans pattern. Với cách tiếp cận này, bạn gọi một constructor không tham số để tạo object, sau đó lần lượt gọi các setter method để thiết lập từng tham số bắt buộc cũng như từng tham số tùy chọn mà bạn quan tâm:

// JavaBeans Pattern - allows inconsistency, mandates mutability
public class NutritionFacts {
    // Parameters initialized to default values (if any)
    private int servingSize  = -1; // Required; no default value
    private int servings     = -1; // Required; no default value
    private int calories     = 0;
    private int fat          = 0;
    private int sodium       = 0;
    private int carbohydrate = 0;

    public NutritionFacts() { }
    // Setters
    public void setServingSize(int val)  { servingSize = val; }
    public void setServings(int val)    { servings = val; }
    public void setCalories(int val)    { calories = val; }
    public void setFat(int val)         { fat = val; }
    public void setSodium(int val)      { sodium = val; }
    public void setCarbohydrate(int val) { carbohydrate = val; }
}

Pattern này không có bất kỳ nhược điểm nào của telescoping constructor pattern. Nó dễ dàng, mặc dù có hơi dài dòng, để tạo các instance, và dễ đọc code kết quả:

NutritionFacts cocaCola = new NutritionFacts();
cocaCola.setServingSize(240);
cocaCola.setServings(8);
cocaCola.setCalories(100);
cocaCola.setSodium(35);
cocaCola.setCarbohydrate(27);

Thật không may, JavaBeans pattern cũng có những nhược điểm nghiêm trọng của riêng nó. Bởi vì quá trình khởi tạo được chia thành nhiều lời gọi khác nhau, một JavaBean có thể rơi vào trạng thái không nhất quán (inconsistent state) trong quá trình được xây dựng. Class không còn khả năng đảm bảo tính nhất quán chỉ bằng cách kiểm tra tính hợp lệ của các tham số trong constructor. Việc cố gắng sử dụng một object khi nó đang ở trạng thái không nhất quán có thể dẫn đến những lỗi xuất hiện rất xa vị trí chứa bug gốc, khiến việc debug trở nên khó khăn.

Một nhược điểm liên quan khác là JavaBeans pattern loại bỏ khả năng tạo ra các class bất biến (immutable class) , đồng thời buộc lập trình viên phải bỏ thêm công sức để đảm bảo tính thread-safe.

Có thể giảm bớt những nhược điểm này bằng cách tự tay “đóng băng” (freeze) object sau khi quá trình xây dựng hoàn tất và không cho phép object được sử dụng trước khi được freeze. Tuy nhiên, biến thể này khá cồng kềnh và hiếm khi được sử dụng trong thực tế. Hơn nữa, nó còn có thể gây ra lỗi ở runtime vì compiler không thể đảm bảo rằng lập trình viên đã gọi phương thức freeze trước khi sử dụng object.

May mắn thay, có một giải pháp thứ ba kết hợp được tính an toàn của telescoping constructor pattern với tính dễ đọc của JavaBeans pattern. Đó là một biến thể của Builder pattern.

Thay vì tạo trực tiếp object mong muốn, client sẽ gọi một constructor (hoặc static factory) với tất cả các tham số bắt buộc để nhận về một builder object. Sau đó, client gọi các phương thức giống setter trên builder để thiết lập từng tham số tùy chọn mà mình quan tâm. Cuối cùng, client gọi một phương thức build không tham số để tạo ra object hoàn chỉnh, object này thường là immutable.

Builder thường được triển khai dưới dạng một static member class nằm bên trong class mà nó xây dựng.

Dưới đây là cách tiếp cận này được sử dụng trong thực tế:

// Builder Pattern
public class NutritionFacts {
    private final int servingSize;
    private final int servings;
    private final int calories;
    private final int fat;
    private final int sodium;
    private final int carbohydrate;

    public static class Builder {
        // Required parameters
        private final int servingSize;
        private final int servings;

        // Optional parameters - initialized to default values
        private int calories      = 0;
        private int fat           = 0;
        private int sodium        = 0;
        private int carbohydrate  = 0;

        public Builder(int servingSize, int servings) {
            this.servingSize = servingSize;
            this.servings    = servings;
        }

        public Builder calories(int val) { 
            calories = val;
            return this; 
        }
        public Builder fat(int val) { 
           fat = val; 
           return this; 
        }
        public Builder sodium(int val) { 
            sodium = val;
            return this; 
        }
        public Builder carbohydrate(int val) { 
            carbohydrate = val;  
            return this; 
        }

        public NutritionFacts build() {
            return new NutritionFacts(this);
        }
    }

    private NutritionFacts(Builder builder) {
        servingSize  = builder.servingSize;
        servings     = builder.servings;
        calories     = builder.calories;
        fat          = builder.fat;
        sodium       = builder.sodium;
        carbohydrate = builder.carbohydrate;
    }
}

NutritionFacts class là immutable, và tất cả các giá trị mặc định của tham số được đặt ở một nơi. Các phương thức setter của builder trả về chính builder để các lời gọi có thể được kết nối, kết quả là một API fluent. Dưới đây là cách mã khách hàng trông như thế này:

NutritionFacts cocaCola = new NutritionFacts.Builder(240, 8).calories(100).sodium(35).carbohydrate(27).build();

Client code theo cách này rất dễ viết và, quan trọng hơn, rất dễ đọc. Builder pattern mô phỏng cơ chế tham số tùy chọn có tên (named optional parameters) vốn có trong các ngôn ngữ như Python và Scala.

Để ngắn gọn, các đoạn kiểm tra tính hợp lệ (validity checks) đã được lược bỏ. Để phát hiện các tham số không hợp lệ sớm nhất có thể, hãy kiểm tra tính hợp lệ của tham số ngay trong constructor và các phương thức của builder. Đối với các bất biến (invariants) liên quan đến nhiều tham số, hãy thực hiện kiểm tra trong constructor được gọi bởi phương thức build.

Để đảm bảo các bất biến này không thể bị phá vỡ bởi các cuộc tấn công hoặc thao tác bất thường, hãy thực hiện việc kiểm tra trên các field của object sau khi sao chép các tham số từ builder sang object (Item 50). Nếu một phép kiểm tra thất bại, hãy ném ra một IllegalArgumentException với thông điệp chi tiết (detail message) chỉ rõ những tham số nào không hợp lệ (Item 75).

Builder pattern đặc biệt phù hợp với các hệ thống class phân cấp (class hierarchies). Hãy sử dụng một hệ thống builder phân cấp song song, trong đó mỗi builder được lồng bên trong class tương ứng của nó. Các abstract class sẽ có abstract builder, còn các concrete class sẽ có concrete builder.

Ví dụ, hãy xem xét một abstract class ở gốc của một hệ thống phân cấp đại diện cho nhiều loại pizza khác nhau:

// Builder pattern for class hierarchies
public abstract class Pizza {
   public enum Topping { HAM, MUSHROOM, ONION, PEPPER, SAUSAGE }
   final Set<Topping> toppings;

   abstract static class Builder<T extends Builder<T>> {
      EnumSet<Topping> toppings = EnumSet.noneOf(Topping.class);
      public T addTopping(Topping topping) {
         toppings.add(Objects.requireNonNull(topping));
         return self();
      }

      abstract Pizza build();

      // Subclasses must override this method to return "this"
      protected abstract T self();
   }
   Pizza(Builder<?> builder) {
      toppings = builder.toppings.clone(); // See Item  50
   }
}

Lưu ý rằng Pizza.Builder là một generic type với một tham số kiểu đệ quy (recursive type parameter) (Item 30). Cơ chế này, kết hợp với phương thức abstract self, cho phép method chaining hoạt động chính xác trong các subclass mà không cần thực hiện ép kiểu (cast).

Giải pháp này là một cách khắc phục thực tế rằng Java không hỗ trợ self type. Kỹ thuật này được gọi là simulated self-type idiom.

Dưới đây là hai subclass cụ thể của Pizza. Một class biểu diễn kiểu pizza truyền thống của New York, còn class kia biểu diễn một chiếc calzone. Class đầu tiên có một tham số bắt buộc là kích thước (size), trong khi class thứ hai cho phép chỉ định liệu sốt (sauce) được đặt bên trong hay bên ngoài:

public class NyPizza extends Pizza {
    public enum Size { SMALL, MEDIUM, LARGE }
    private final Size size;

    public static class Builder extends Pizza.Builder<Builder> {
        private final Size size;

        public Builder(Size size) {
            this.size = Objects.requireNonNull(size);
        }

        @Override public NyPizza build() {
            return new NyPizza(this);
        }

        @Override protected Builder self() { return this; }
    }

    private NyPizza(Builder builder) {
        super(builder);
        size = builder.size;
    }
}

public class Calzone extends Pizza {
    private final boolean sauceInside;

    public static class Builder extends Pizza.Builder<Builder> {
        private boolean sauceInside = false; // Default

        public Builder sauceInside() {
            sauceInside = true;
            return this;
        }

        @Override public Calzone build() {
            return new Calzone(this);
        }

        @Override protected Builder self() { return this; }
    }

    private Calzone(Builder builder) {
        super(builder);
        sauceInside = builder.sauceInside;
    }
}

Lưu ý rằng phương thức build trong builder của mỗi subclass được khai báo để trả về đúng kiểu subclass tương ứng: phương thức build của NyPizza.Builder trả về NyPizza, còn phương thức build của Calzone.Builder trả về Calzone.

Kỹ thuật này, trong đó một phương thức ở subclass được khai báo trả về một kiểu con (subtype) của kiểu trả về được khai báo trong superclass, được gọi là covariant return typing.

Cơ chế này cho phép client sử dụng các builder mà không cần thực hiện ép kiểu (cast).

Client code cho các hierarchical builders này về cơ bản giống hệt với client code của builder đơn giản trong ví dụ NutritionFacts. Để ngắn gọn, đoạn client code dưới đây giả định rằng các hằng số enum đã được import bằng static import:

NyPizza pizza = new NyPizza.Builder(SMALL)
        .addTopping(SAUSAGE).addTopping(ONION).build();
Calzone calzone = new Calzone.Builder()
        .addTopping(HAM).sauceInside().build();

Một ưu điểm nhỏ của builder so với constructor là builder có thể hỗ trợ nhiều tham số varargs, bởi vì mỗi tham số được thiết lập thông qua một phương thức riêng biệt. Ngoài ra, builder còn có thể gom các tham số được truyền qua nhiều lần gọi cùng một phương thức vào một field duy nhất, như đã được minh họa trước đó với phương thức addTopping.

Builder pattern cũng rất linh hoạt. Một builder có thể được sử dụng nhiều lần để tạo ra nhiều object khác nhau. Các tham số trong builder có thể được điều chỉnh giữa các lần gọi phương thức build để tạo ra những object với cấu hình khác nhau. Builder thậm chí có thể tự động điền một số field trong quá trình tạo object, chẳng hạn như một số serial tăng dần mỗi khi một object mới được tạo ra.

Tuy nhiên, Builder pattern cũng có những nhược điểm. Để tạo một object, trước tiên bạn phải tạo builder của nó. Mặc dù chi phí tạo builder thường không đáng kể trong thực tế, nó vẫn có thể trở thành vấn đề trong các tình huống cực kỳ nhạy cảm về hiệu năng.

Ngoài ra, Builder pattern dài dòng (verbose) hơn so với telescoping constructor pattern, vì vậy chỉ nên sử dụng khi số lượng tham số đủ lớn để mang lại lợi ích rõ rệt, chẳng hạn từ bốn tham số trở lên. Tuy nhiên, cần lưu ý rằng trong tương lai bạn có thể sẽ bổ sung thêm tham số cho class.

Nếu ban đầu bạn sử dụng constructor hoặc static factory rồi sau này chuyển sang builder khi class phát triển đến mức số lượng tham số trở nên khó kiểm soát, các constructor hoặc static factory cũ sẽ trở nên lạc lõng và khó chịu như một "cái gai trong mắt". Vì vậy, trong nhiều trường hợp, việc bắt đầu với builder ngay từ đầu sẽ là lựa chọn tốt hơn.

Tóm lại, Builder pattern là lựa chọn phù hợp khi thiết kế các class mà constructor hoặc static factory của chúng sẽ có nhiều hơn một vài tham số, đặc biệt khi nhiều tham số là tùy chọn hoặc có cùng kiểu dữ liệu. Client code sử dụng builder dễ đọc và dễ viết hơn rất nhiều so với telescoping constructors, đồng thời builder cũng an toàn hơn đáng kể so với JavaBeans pattern.

Summary

  1. Telescoping Constructor Pattern

    Tạo nhiều constructor với số lượng tham số tăng dần.

    • Ưu điểm
      • Đơn giản để triển khai.
      • Có thể tạo immutable object.
    • Nhược điểm
      • Khó đọc và khó sử dụng khi số lượng tham số tăng.
      • Người đọc khó biết từng giá trị truyền vào đại diện cho điều gì.
      • Dễ xảy ra bug khi nhiều tham số có cùng kiểu dữ liệu.
      • Khó mở rộng khi class phát triển.
  2. JavaBeans Pattern Tạo object bằng constructor không tham số, sau đó thiết lập giá trị thông qua các setter.

    • Ưu điểm
      • Dễ đọc hơn constructor dài.
      • Chỉ cần thiết lập những thuộc tính cần thiết.
    • Nhược điểm
      • Object có thể ở trạng thái không nhất quán trong quá trình khởi tạo.
      • Khó đảm bảo tính hợp lệ của object.
      • Không hỗ trợ immutable object.
      • Gây thêm gánh nặng về thread-safety.
      • Lỗi thường xuất hiện ở runtime thay vì compile-time.
  3. Builder Pattern Tách quá trình thu thập dữ liệu và tạo object thành hai bước:

    NutritionFacts cocaCola = new NutritionFacts.Builder(240, 8).calories(100).sodium(35).build();
    

    Builder giữ trạng thái tạm thời, còn object cuối cùng thường là immutable.

    • Ưu điểm

      • Code dễ đọc và dễ viết.
      • Hỗ trợ nhiều tham số tùy chọn.
      • Tránh nhầm lẫn vị trí tham số.
      • Dễ kiểm tra tính hợp lệ trước khi tạo object.
      • Hỗ trợ immutable object.
      • Dễ mở rộng khi class phát triển.
      • Hỗ trợ class hierarchy thông qua hierarchical builders.
      • Có thể tái sử dụng builder để tạo nhiều object khác nhau.
      • Hỗ trợ nhiều varargs parameter.
    • Nhược điểm

      • Cần tạo thêm builder object.
      • Verbose hơn constructor đơn giản.
      • Không đáng dùng nếu class chỉ có rất ít tham số.

All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí