ข้าม​ไป​ยัง​เนื้อหา

Builder

ค่อย ๆ กำหนด​คุณลักษณะ​ของ object ที​ละ​ขั้น​ด้วย​หลาย method

เมื่อ object หนึ่ง​มี​คุณลักษณะ​ให้​กำหนด​จำนวน​มาก และ​บาง​ส่วน​เป็น optional เรา​จะ​เจอ​ปัญหา​ที่​เรียก​ว่า “telescoping constructor” — คือ​ต้อง​เขียน constructor หลาย​ตัว​ซ้อน​กัน​เพื่อ​รองรับ​ทุก combination ของ parameter ที่​จำเป็น​กับ​ที่​ไม่​จำเป็น เช่น Report(title), Report(title, author), Report(title, author, sections) ไล่​ไป​เรื่อย ๆ จน​อ่าน​ยาก​และ​แก้​ยาก ทาง​เลือก​ที่​แย่​ไม่​แพ้​กัน​คือ​ยัด​ทุก​อย่าง​ลง​ใน constructor เดียว​ที่​รับ parameter ยาว​เป็น​สิบ​ตัว หรือ​ส่ง parameter object ที่​ซับซ้อน​เข้าไป​แทน

Builder แก้​ปัญหา​นี้​ด้วย​การ แยก​กระบวนการ​สร้าง object ออก​จาก​ตัว representation ของ​มัน แทนที่​จะ​มี method เดียว​ที่​สร้าง​และ​คืน​ค่า object ทันที​แบบ Factory Method, Builder เปิด หลาย method ที่​ค่อย ๆ นิยาม​คุณลักษณะ​ของ object ที​ละ​ขั้น แล้ว​ปิด​ท้าย​ด้วย method Build() ที่​คืน object ที่​ประกอบ​เสร็จ​สมบูรณ์ ระหว่าง​ทาง​เรา​สามารถ​ข้าม​ขั้น​ที่​เป็น optional ไป​ได้ และ construction logic ที่​ซับซ้อน (เช่น การ​ตรวจ invariant หรือ​คำนวณ​ค่าที่​ขึ้น​กับ​กัน) ก็​ถูก​ซ่อน​ไว้​ใน​ตัว builder เอง ไม่​รั่วไหล​ออก​ไป​ให้ client ต้อง​รู้

แนวคิด​ตาม Gang of Four คือ​มี Director ที่​รู้​ลำดับ​ขั้นตอน​การ​สร้าง คอย​เรียก Builder interface ซึ่ง ConcreteBuilder แต่ละ​ตัว​จะ​ประกอบ Product ออก​มา​ต่าง​กัน​ได้ แม้ construction process จะ​เหมือน​กัน — เช่น builder เดียวกัน​อาจ​สร้าง​รายงาน​แบบ HTML หรือ PDF ก็ได้ ใน code จริง​ยุค​ปัจจุบัน (โดย​เฉพาะ​สาย .NET/Java) มัก​ตัด Director ออก​แล้ว​ให้ client เรียก builder แบบ fluent เอง เพราะ​เรียบ​ง่าย​กว่า​และ​ยัง​คง​ประโยชน์​หลัก​ไว้​ครบ ตัวอย่าง​ที่​คุ้น​เคย​ที่สุด​คือ StringBuilder ใน .NET และ Java

classDiagram
    class Director {
      -IBuilder builder
      +Construct()
    }
    class IBuilder {
      <<interface>>
      +BuildTitle(title)
      +AddSection(section)
      +Build() Report
    }
    class ReportBuilder {
      -Report report
      +BuildTitle(title)
      +AddSection(section)
      +Build() Report
    }
    class Report {
      +Title
      +Sections
    }
    Director o-- IBuilder
    IBuilder <|.. ReportBuilder
    ReportBuilder ..> Report
  • Builder (IBuilder) — นิยาม interface กลาง​ของ​ขั้นตอน​การ​สร้าง ให้ ConcreteBuilder แต่ละ​ตัว​ไป implement ต่าง​กัน​ได้
  • ConcreteBuilder (ReportBuilder) — เก็บ state ระหว่าง​การ​ประกอบ, implement แต่ละ​ขั้น, และ​คืน​ผลลัพธ์​สุดท้าย​ผ่าน Build()
  • Product (Report) — object ที่​ถูก​ประกอบ​ขึ้น ไม่​จำเป็น​ต้อง​รู้จัก builder เลย
  • Director — (มี​หรือ​ไม่มี​ก็ได้) ห่อ​หุ้ม​ลำดับ​ขั้น​ที่​ใช้​ซ้ำ​ได้ เช่น “สร้าง​รายงาน​สรุป​ผู้​บริหาร” ก็​คือ​เรียก builder ตาม​ลำดับ​คงที่​ชุด​หนึ่ง​เสมอ

ใน​รูปแบบ​ดั้งเดิม​ของ GoF, client สร้าง ConcreteBuilder แล้ว​ส่ง​ให้ Director ซึ่ง​จะ​ขับ​เคลื่อน​ลำดับ​การ​เรียก method ของ builder ตาม​ที่​มัน​รู้ Director ไม่รู้จัก product โดยตรง มัน​รู้​แค่ “ต้อง​เรียก builder ทำ​อะไร​ก่อน​หลัง” ส่วน builder เป็น​คน​เดียว​ที่​รู้​รายละเอียด​ว่า​จะ​ประกอบ product ยังไง

ใน code C#/Java สมัย​ใหม่ มัก​ตัด Director ออก​และ​ใช้ fluent interface: แต่ละ method ของ builder คืน this (หรือ IBuilder type เดิม) ทำให้​เรียก​ต่อ​กัน​เป็น​ลูกโซ่​ได้​อ่าน​ลื่น​เหมือน​ประโยค​ภาษา​อังกฤษ — Martin Fowler เรียก​สไตล์​นี้​ว่า fluent interface และ​ชี้​ว่า​มัน​เหมือน internal DSL มากกว่า​จะ​เป็น​แค่ method chaining ธรรมดา จุด​สำคัญ​คือ client เลือก​เรียก​เฉพาะ​ขั้น​ที่​ต้องการ ข้าม​ขั้น​ที่​เป็น optional ได้ แล้ว​ปิด​ท้าย​ด้วย Build() เพื่อ​รับ product ที่​สมบูรณ์

sequenceDiagram
    participant Client
    participant Builder as ReportBuilder
    participant Product as Report
    Client->>Builder: new ReportBuilder()
    Client->>Builder: Title(t)
    Builder-->>Client: this
    Client->>Builder: AddSection(s1)
    Builder-->>Client: this
    Client->>Builder: AddSection(s2)
    Builder-->>Client: this
    Client->>Builder: Build()
    Builder->>Product: สร้าง Report ที่สมบูรณ์
    Builder-->>Client: Report

จุด​ที่​ควร​ระวัง​คือ builder ต้อง​รับประกัน​ว่า Build() จะ​ไม่​คืน object ที่​อยู่​ใน​สถานะ​ไม่​ถูกต้อง (invalid state) — ถ้า​มี field ที่​บังคับ ควร​ตรวจ​ใน Build() แล้ว​โยน exception ถ้า​ยัง​ไม่​ครบ ไม่ใช่​ปล่อย​ให้ product ครึ่ง ๆ กลาง ๆ หลุด​ออก​ไป

// Product — object ที่ค่อนข้างซับซ้อน มีทั้ง field บังคับและ optional
public class Report
{
public string Title { get; init; } = "";
public string? Author { get; init; }
public IReadOnlyList<string> Sections { get; init; } = Array.Empty<string>();
public bool IncludeSummary { get; init; }
}
// Builder interface — นิยามขั้นตอนที่เปิดให้ client เรียก
public interface IReportBuilder
{
IReportBuilder Title(string title);
IReportBuilder Author(string author);
IReportBuilder AddSection(string section);
IReportBuilder WithSummary();
Report Build();
}
// ConcreteBuilder — ประกอบทีละขั้นแบบ fluent แล้วตรวจ invariant ก่อนคืนผล
public class ReportBuilder : IReportBuilder
{
private string _title = "";
private string? _author;
private readonly List<string> _sections = new();
private bool _includeSummary;
public IReportBuilder Title(string title)
{
_title = title;
return this;
}
public IReportBuilder Author(string author)
{
_author = author;
return this;
}
public IReportBuilder AddSection(string section)
{
_sections.Add(section);
return this;
}
public IReportBuilder WithSummary()
{
_includeSummary = true;
return this;
}
public Report Build()
{
// ตรวจ invariant ก่อนปล่อย product ออกไป กัน state ที่ไม่สมบูรณ์
if (string.IsNullOrWhiteSpace(_title))
throw new InvalidOperationException("Report ต้องมี Title เสมอ");
return new Report
{
Title = _title,
Author = _author,
Sections = _sections.AsReadOnly(),
IncludeSummary = _includeSummary
};
}
}
// Director (ทางเลือก) — ห่อหุ้มลำดับขั้นที่ใช้ซ้ำได้เป็นสูตรสำเร็จ
public static class ReportDirector
{
public static Report BuildExecutiveSummary(IReportBuilder builder, string title, string author) =>
builder.Title(title)
.Author(author)
.WithSummary()
.AddSection("บทสรุปผู้บริหาร")
.Build();
}
// การใช้งานแบบ fluent ตรง ๆ โดยไม่ผ่าน Director
var report = new ReportBuilder()
.Title("ยอดขายรายเดือน")
.AddSection("สรุป")
.AddSection("รายละเอียด") // เพิ่มเฉพาะขั้นที่ต้องการ
.Build();
// การใช้งานผ่าน Director เมื่อมีสูตรสร้างที่ใช้ซ้ำบ่อย ๆ
var summary = ReportDirector.BuildExecutiveSummary(new ReportBuilder(), "Q3 Review", "สมชาย");
  • object ที่​จะ​สร้าง​มี​คุณลักษณะ​จำนวน​มาก และ​หลาย​ส่วน​เป็น optional หรือ​มี​ค่า default
  • ต้องการ​หลีก​เลี่ยง telescoping constructor หรือ constructor ที่​รับ parameter ยาว​เกิน​ไป (ดู Long Parameter List)
  • construction logic มี​ความ​ซับซ้อน เช่น ต้อง​ตรวจ invariant, คำนวณ​ค่าที่​ขึ้น​กับ​กัน หรือ​ประกอบ object ที​ละ​ส่วน​ตาม​ลำดับ​ที่​กำหนด
  • ต้องการ​สร้าง representation ที่​ต่าง​กัน​จาก construction process เดียวกัน (เช่น builder เดียว​ส่ง​ออก​ได้​ทั้ง HTML และ PDF report)
  • ต้องการ object ที่​เป็น immutable หลัง​สร้าง​เสร็จ แต่​ระหว่าง​สร้าง​ต้อง​ปรับ​แต่ง​ได้​หลาย​ขั้น
  • object เรียบ​ง่าย มี​ไม่​กี่ field ทุก field บังคับ​หมด — constructor ธรรมดา​หรือ object initializer ก็​เพียงพอ
  • ทีม​ไม่​ต้องการ​ดูแล class เพิ่ม​อีก​ชุด (interface + concrete builder) สำหรับ object ที่​ไม่​ค่อย​เปลี่ยนแปลง​รูปแบบ
  • ใช้ record type หรือ named/optional parameters ของ​ภาษา​สมัย​ใหม่ (เช่น C# 12 primary constructor, required properties) แก้​ปัญหา​ได้​ตรง​จุด​กว่า​อยู่​แล้ว
  • ต้องการ object ที่​ต้อง​คง​ความ valid ตลอด​เวลา​แม้​ระหว่าง​สร้าง (Builder ยอม​ให้ state กลาง ๆ ไม่​สมบูรณ์​ได้​ก่อน​เรียก Build())
ด้านรายละเอียด
ข้อดีสร้าง object ที​ละ​ขั้น ข้าม​ขั้น​ได้​ตาม​ต้องการ (Single Responsibility ของ construction logic)
ข้อดีใช้ construction process เดียวกัน​สร้าง representation ต่าง​กัน​ได้ (ผ่าน ConcreteBuilder หลาย​ตัว)
ข้อดีซ่อน state กลาง​ที่​ยัง​ไม่​สมบูรณ์​จาก client จนกว่า​จะ​เรียก Build()
ข้อดีfluent interface อ่าน​ง่าย ใกล้​เคียง​ภาษา​ธรรมชาติ
ข้อ​เสียเพิ่ม​จำนวน class/code สำหรับ object ที่​อาจ​ไม่​ซับซ้อน​พอ​จะ​คุ้ม​ค่า
ข้อ​เสียถ้า​ลืม​เรียก Build() หรือ​ลืม​กำหนด field บังคับ อาจ​ได้ product ที่​ไม่​สมบูรณ์ ต้อง​มี validation คอย​กัน
ข้อ​เสียfluent method ที่​คืน this มัก​ละเมิด Command-Query Separation (แต่​เป็น trade-off ที่​ยอมรับ​ได้​ใน​บริบท​นี้)