Static Cling
coupling ที่เกิดจากการเรียกใช้ static/global — ทดสอบยาก
ปัญหาคืออะไร
หัวข้อที่มีชื่อว่า “ปัญหาคืออะไร”Static Cling ตั้งชื่อล้อกับ keyword static ใน C# ที่ทำให้ member หนึ่งกลายเป็น global ทั่วทั้ง application — เรียกได้จากทุกที่โดยไม่ต้องมี instance คำว่า “cling” สื่อถึงอาการที่ code ผู้เรียก “ติด” อยู่กับ static member นั้นแบบแยกไม่ออก
antipattern นี้อธิบาย coupling ที่ไม่พึงประสงค์ซึ่งเกิดจากการเข้าถึง static (global) functionality ไม่ว่าจะเป็น static field, static property หรือ static method เมื่อ class ใดเรียก static member ของอีก class ตรง ๆ ภายใน method (ไม่ใช่ผ่าน parameter หรือ constructor) class นั้นก็ผูกติดกับ implementation ที่เจาะจงตัวเดียวอย่างถาวร ไม่มีทางสับเปลี่ยนพฤติกรรมได้จากภายนอก
ปัญหานี้ไม่ได้จำกัดอยู่แค่ C#/static — ภาษาที่ไม่มี keyword static ก็เป็นได้เหมือนกันผ่าน module-level function, global variable หรือ free function ที่เรียกตรง ๆ จากทุกที่ หัวใจของปัญหาคือ “การเข้าถึง global scope โดยตรงจากภายใน code ที่ควรพึ่งพา dependency ที่ประกาศชัดเจน” ไม่ใช่ตัว keyword ของภาษาใดภาษาหนึ่ง
ทำไมถึงดูน่าใช้
หัวข้อที่มีชื่อว่า “ทำไมถึงดูน่าใช้”- เรียกง่าย ไม่ต้องคิดเรื่อง instance — พิมพ์
ClassName.Method()ได้ทันที ไม่ต้อง new อะไรก่อน ไม่ต้องผ่าน constructor parameter ไม่ต้อง wiring ผ่าน DI container - รู้สึกว่า “ไม่มี state” จริง ๆ — utility method อย่าง logging, formatting, การคำนวณวันที่ ดูเหมือนเป็น pure function ที่ไม่มีผลข้างเคียงต้องกังวล จึงไม่รู้สึกผิดที่จะทำเป็น static
- IDE auto-complete พาไปเจอ — เมื่อพิมพ์ชื่อ class แล้วเห็น static method โผล่ในรายการ ก็เรียกใช้ได้เลยโดยไม่ต้องคิดถึง dependency graph ของระบบ
- ไม่ต้องแก้ constructor signature — การเพิ่ม dependency ใหม่ผ่าน static call ไม่กระทบ signature ของ constructor หรือ method เดิม เพิ่ม/ลบได้โดยไม่ต้อง touch caller ที่มีอยู่ ดูเหมือนเป็นการเปลี่ยนแปลงที่ “ปลอดภัย” กว่า
ทำไมถึงเป็นปัญหา
หัวข้อที่มีชื่อว่า “ทำไมถึงเป็นปัญหา”- ซ่อน dependency ที่แท้จริง — constructor และ method signature ของ class ไม่ได้บอกความจริงอีกต่อไปว่ามันต้องพึ่งพาอะไรบ้าง ผู้ที่อ่านหรือ new instance ขึ้นมาใช้จะไม่มีทางรู้ (โดยไม่เปิดดู implementation ข้างใน) ว่า method นี้แตะ file system, database หรือ network จริง ๆ ขัดกับ Explicit Dependencies Principle ที่บอกว่า method ควรประกาศทุกสิ่งที่มันต้องการอย่างตรงไปตรงมา
- ทดสอบไม่ได้หรือทดสอบยากมาก — เพราะไม่มี seam ให้แทนที่ด้วย test double test unit ของ method ที่เรียก static ตรง ๆ จึงลาก dependency จริง (file system, เวลาปัจจุบัน, network) เข้ามาด้วยเสมอ กลายเป็น integration test ที่ช้าและเปราะ หรือบางกรณีก็รันไม่ได้เลยในสภาพแวดล้อม CI
- ปัญหา thread-safety — static state ที่ mutable จะถูก share ข้าม thread โดยอัตโนมัติ นักพัฒนาต้อง synchronize เองอย่างระมัดระวัง ซึ่งมักถูกมองข้ามจนเกิด race condition ที่ debug ยาก
- ควบคุม lifetime ไม่ได้ — ไม่มีใคร “เป็นเจ้าของ” วงจรชีวิตของ static state ไม่มีจุด dispose หรือ reset ที่ชัดเจน ทำให้ state รั่วไหลข้าม test case หรือข้าม request ในเว็บ app
- มักมาคู่กับ Singleton และ Service Locator — Singleton ที่เข้าถึงผ่าน static property (
Instance) ก็เป็น Static Cling รูปแบบหนึ่ง เพราะทุกจุดเรียกยังคงผูกกับ implementation เดียวตายตัว ส่วน Service Locator ที่ใช้ static locator ก็ซ่อน dependency ในลักษณะเดียวกัน เพียงแต่ซ่อนลึกกว่าหนึ่งชั้น — ทุกจุดเรียกLocator.Resolve<T>()ก็ยัง cling กับ global registry นั้นอยู่ดี
ตัวอย่าง
หัวข้อที่มีชื่อว่า “ตัวอย่าง”ตัวอย่างคลาสสิก: method Checkout ที่เรียก static OrderLogger.LogOrder ซึ่งเขียนลง file system ตรง ๆ
// Static Cling: Checkout ผูกติดกับ static method โดยตรงpublic static class OrderLogger{ public static void LogOrder(Order order) { // เขียนลง file system ตรง ๆ ไม่มีทางแทนที่จากภายนอก File.AppendAllText("orders.log", $"{DateTime.UtcNow}: Order {order.Id} placed\n"); }}
public class Checkout{ public void Complete(Order order) { // ประมวลผลการชำระเงิน ... OrderLogger.LogOrder(order); // เรียก static ตรง ๆ — unit test ต้องแตะ file system จริง }}เมื่อจะเขียน unit test ให้ Checkout.Complete โดยไม่แตะ disk จริง จะพบว่าทำไม่ได้เลย เพราะไม่มี seam ใด ๆ ให้แทนที่ OrderLogger ด้วย test double — ต้องพึ่ง reflection/bytecode-rewriting tool พิเศษ หรือยอมให้ test เป็น integration test ที่ช้าและพึ่งพาสภาพแวดล้อม
flowchart TD
subgraph Before
A1[Checkout] --> A2[Static OrderLogger LogOrder]
A2 --> A3[File System]
end
subgraph After
B1[Checkout] --> B2[IOrderLogger]
B2 --> B3[FileOrderLogger]
B2 --> B4[FakeOrderLogger for tests]
end
ทางแก้และการ refactor
หัวข้อที่มีชื่อว่า “ทางแก้และการ refactor”ทางแก้หลักคือยึด Explicit Dependencies Principle และ Dependency Injection: แปลง static call ให้เป็น instance call ผ่าน interface แล้วส่ง (inject) instance นั้นเข้ามาทาง constructor แทนที่จะให้ class ไปเรียกหาเอง
public interface IOrderLogger{ void LogOrder(Order order);}
public class FileOrderLogger : IOrderLogger{ public void LogOrder(Order order) { File.AppendAllText("orders.log", $"{DateTime.UtcNow}: Order {order.Id} placed\n"); }}
public class Checkout{ private readonly IOrderLogger _orderLogger;
// Explicit Dependencies: ประกาศ dependency ทั้งหมดผ่าน constructor อย่างตรงไปตรงมา public Checkout(IOrderLogger orderLogger) { _orderLogger = orderLogger ?? throw new ArgumentNullException(nameof(orderLogger)); }
public void Complete(Order order) { // ประมวลผลการชำระเงิน ... _orderLogger.LogOrder(order); }}
// ใน test: แทนที่ด้วย fake ได้ง่าย ไม่ต้องแตะ file system จริงpublic class FakeOrderLogger : IOrderLogger{ public List<Order> LoggedOrders { get; } = new(); public void LogOrder(Order order) => LoggedOrders.Add(order);}หลักการเบื้องหลังคือ Strategy pattern: Checkout ไม่รู้และไม่สนใจว่า logger ตัวจริงเป็น implementation แบบไหน มันรู้แค่ contract ผ่าน IOrderLogger ทำให้สลับ implementation ระหว่าง production กับ test ได้อย่างอิสระ และ container DI (composition root) เป็นผู้ตัดสินใจว่าจะสร้างและจัดการวงจรชีวิตของ FileOrderLogger อย่างไร
สำหรับ code เก่า (legacy) ที่ refactor แบบเต็มรูปแบบทันทีไม่ได้ Martin Fowler เสนอเทคนิค Static Substitution เป็นขั้นบันได: ย้าย static field/method ให้เป็น instance member ของ singleton ก่อน แล้วเปิดช่องให้ substitute singleton instance นั้นได้ (เช่น method loadInstance(stub)) วิธีนี้ยังไม่ใช่ทางออกสุดท้าย — Singleton เองก็ยังเป็น global access point ที่ควรเดินหน้าต่อไปเป็น constructor injection แบบเต็มตัวในที่สุด และ Google Testing Blog แนะนำแนวทางใกล้เคียงกันสำหรับ legacy Java: ห่อ static ไว้หลัง interface wrapper หรือใช้เทคนิค subclass-and-override ของ Michael Feathers (แยก static call ไว้ใน protected method แล้ว subclass เพื่อ stub ใน test)
ข้อควรระวัง: การเปลี่ยน static เป็น public static Instance (Singleton) เฉย ๆ ไม่ได้แก้ปัญหา Static Cling อย่างสมบูรณ์ เพราะทุกจุดเรียกยังคง cling กับ global access point เดียวอยู่ดี ทางที่ถูกคือฉีด instance ที่เจาะจงเข้าไปในแต่ละ class ที่ต้องการจริง ๆ ผ่าน constructor ไม่ใช่ให้ทุก class ไปเรียก Instance เอง
ที่เกี่ยวข้อง
หัวข้อที่มีชื่อว่า “ที่เกี่ยวข้อง”- Singleton
- Service Locator
- Dependency Injection
- Explicit Dependencies Principle
- Dependency Inversion Principle
- Strategy Pattern