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

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

ทาง​แก้​หลัก​คือ​ยึด 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 เอง